From 9b911c9722dd58f85c2a5d6f5d0ae77c02cf240f Mon Sep 17 00:00:00 2001 From: briank <98683954+kdbrian@users.noreply.github.com> Date: Fri, 8 Aug 2025 07:37:00 +0300 Subject: [PATCH 01/25] Initialize koin --- .../java/com/kdbrian/templated/TemplatedApp.kt | 14 ++++++++++++++ .../com/kdbrian/templated/di/templateModules.kt | 7 +++++++ 2 files changed, 21 insertions(+) create mode 100644 app/src/main/java/com/kdbrian/templated/di/templateModules.kt diff --git a/app/src/main/java/com/kdbrian/templated/TemplatedApp.kt b/app/src/main/java/com/kdbrian/templated/TemplatedApp.kt index ffdcc5e..7d3258d 100644 --- a/app/src/main/java/com/kdbrian/templated/TemplatedApp.kt +++ b/app/src/main/java/com/kdbrian/templated/TemplatedApp.kt @@ -1,6 +1,10 @@ package com.kdbrian.templated import android.app.Application +import com.kdbrian.templated.di.templateModules +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin import timber.log.Timber class TemplatedApp : Application() { @@ -9,5 +13,15 @@ class TemplatedApp : Application() { if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) + + startKoin { + + androidLogger() + androidContext(this@TemplatedApp) + + modules( + templateModules, + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/templated/di/templateModules.kt b/app/src/main/java/com/kdbrian/templated/di/templateModules.kt new file mode 100644 index 0000000..bd75208 --- /dev/null +++ b/app/src/main/java/com/kdbrian/templated/di/templateModules.kt @@ -0,0 +1,7 @@ +package com.kdbrian.templated.di + +import org.koin.dsl.module + +val templateModules = module { + +} \ No newline at end of file From 69c09403753bd8139db81282a9907a9709f60e18 Mon Sep 17 00:00:00 2001 From: briank <98683954+kdbrian@users.noreply.github.com> Date: Fri, 8 Aug 2025 07:37:00 +0300 Subject: [PATCH 02/25] Initialize koin --- gradle/libs.versions.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5d3e97..6b26523 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,6 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junitJupiter" } -junit-junit = { group = "junit", name = "junit", version.ref = "junitJunit" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koinBom" } koin-core = { module = "io.insert-koin:koin-core" } From dab7a7376095ea3a71d91a127d921af77d796b8b Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 11:04:16 +0300 Subject: [PATCH 03/25] refactor/ - initial project structure. - set-up components. --- app/build.gradle.kts | 10 +- .../ExampleInstrumentedTest.kt | 2 +- .../EnvironmentStatusDisplayAndroidTest.kt | 40 ------ .../com/kdbrian/templated/GreetingEnvTest.kt | 26 ---- .../{templated => sage}/MainActivity.kt | 8 +- .../TemplatedApp.kt => sage/Sage.kt} | 8 +- .../{templated => sage}/di/templateModules.kt | 2 +- .../ui/composables/CircularProfileAvatar.kt | 40 ++++++ .../sage/ui/composables/RoundedInputField.kt | 72 +++++++++++ .../kdbrian/sage/ui/composables/RowButton.kt | 69 +++++++++++ .../kdbrian/sage/ui/composables/TopicCard.kt | 54 ++++++++ .../kdbrian/sage/ui/screens/CreateScreen.kt | 56 +++++++++ .../{templated => sage}/ui/theme/Color.kt | 2 +- .../{templated => sage}/ui/theme/Theme.kt | 5 +- .../{templated => sage}/ui/theme/Type.kt | 2 +- .../templated/EnvironmentStatusDisplay.kt | 117 ------------------ .../res/drawable-nodpi/current_premium.webp | Bin 0 -> 96000 bytes .../{templated => sage}/ExampleUnitTest.kt | 2 +- gradle/libs.versions.toml | 9 +- settings.gradle.kts | 2 +- 20 files changed, 322 insertions(+), 204 deletions(-) rename app/src/androidTest/java/com/kdbrian/{templated => sage}/ExampleInstrumentedTest.kt (95%) delete mode 100644 app/src/androidTest/java/com/kdbrian/templated/EnvironmentStatusDisplayAndroidTest.kt delete mode 100644 app/src/androidTest/java/com/kdbrian/templated/GreetingEnvTest.kt rename app/src/main/java/com/kdbrian/{templated => sage}/MainActivity.kt (90%) rename app/src/main/java/com/kdbrian/{templated/TemplatedApp.kt => sage/Sage.kt} (74%) rename app/src/main/java/com/kdbrian/{templated => sage}/di/templateModules.kt (65%) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt rename app/src/main/java/com/kdbrian/{templated => sage}/ui/theme/Color.kt (86%) rename app/src/main/java/com/kdbrian/{templated => sage}/ui/theme/Theme.kt (94%) rename app/src/main/java/com/kdbrian/{templated => sage}/ui/theme/Type.kt (96%) delete mode 100644 app/src/main/java/com/kdbrian/templated/EnvironmentStatusDisplay.kt create mode 100644 app/src/main/res/drawable-nodpi/current_premium.webp rename app/src/test/java/com/kdbrian/{templated => sage}/ExampleUnitTest.kt (91%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c63a36..20b716b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,11 +19,11 @@ plugins { } android { - namespace = "com.kdbrian.templated" + namespace = "com.kdbrian.sage" compileSdk = 35 defaultConfig { - applicationId = "com.kdbrian.templated" + applicationId = "com.kdbrian.sage" minSdk = 26 targetSdk = 35 versionCode = 1 @@ -108,6 +108,12 @@ dependencies { implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) + //nav + implementation(libs.androidx.navigation.compose) + //coil + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + } \ No newline at end of file diff --git a/app/src/androidTest/java/com/kdbrian/templated/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/kdbrian/sage/ExampleInstrumentedTest.kt similarity index 95% rename from app/src/androidTest/java/com/kdbrian/templated/ExampleInstrumentedTest.kt rename to app/src/androidTest/java/com/kdbrian/sage/ExampleInstrumentedTest.kt index d6d14a2..920387d 100644 --- a/app/src/androidTest/java/com/kdbrian/templated/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/kdbrian/sage/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.kdbrian.templated +package com.kdbrian.sage import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/app/src/androidTest/java/com/kdbrian/templated/EnvironmentStatusDisplayAndroidTest.kt b/app/src/androidTest/java/com/kdbrian/templated/EnvironmentStatusDisplayAndroidTest.kt deleted file mode 100644 index 2703577..0000000 --- a/app/src/androidTest/java/com/kdbrian/templated/EnvironmentStatusDisplayAndroidTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.kdbrian.templated - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class EnvironmentStatusDisplayAndroidTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Test - fun environment_and_connectivity_displayed_when_connected() { - val expectedEnv = "Environment: ${BuildConfig.ENV}" - - composeTestRule.setContent { - EnvironmentStatusDisplay(isConnected = true) - } - - composeTestRule.onNodeWithText("Connected").assertIsDisplayed() - composeTestRule.onNodeWithText(expectedEnv).assertIsDisplayed() - } - - @Test - fun environment_and_connectivity_displayed_when_disconnected() { - val expectedEnv = "Environment: ${BuildConfig.ENV}" - - composeTestRule.setContent { - EnvironmentStatusDisplay(isConnected = false) - } - - composeTestRule.onNodeWithText("No Internet").assertIsDisplayed() - composeTestRule.onNodeWithText(expectedEnv).assertIsDisplayed() - } -} diff --git a/app/src/androidTest/java/com/kdbrian/templated/GreetingEnvTest.kt b/app/src/androidTest/java/com/kdbrian/templated/GreetingEnvTest.kt deleted file mode 100644 index cb87975..0000000 --- a/app/src/androidTest/java/com/kdbrian/templated/GreetingEnvTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.kdbrian.templated - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 - -@RunWith(JUnit4::class) -class GreetingEnvTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Test - fun greeting_should_display_env_as_name() { - val expectedText = "Hello ${BuildConfig.ENV}!" - - composeTestRule - .onNodeWithText(expectedText) - .assertIsDisplayed() - } -} diff --git a/app/src/main/java/com/kdbrian/templated/MainActivity.kt b/app/src/main/java/com/kdbrian/sage/MainActivity.kt similarity index 90% rename from app/src/main/java/com/kdbrian/templated/MainActivity.kt rename to app/src/main/java/com/kdbrian/sage/MainActivity.kt index b49fac8..1ca9e62 100644 --- a/app/src/main/java/com/kdbrian/templated/MainActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -1,4 +1,4 @@ -package com.kdbrian.templated +package com.kdbrian.sage import android.os.Bundle import androidx.activity.ComponentActivity @@ -11,14 +11,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.kdbrian.templated.ui.theme.TemplatedTheme +import com.kdbrian.sage.ui.theme.SageTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - TemplatedTheme { + SageTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Greeting( name = "Android", @@ -41,7 +41,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { @Preview(showBackground = true) @Composable fun GreetingPreview() { - TemplatedTheme { + SageTheme { Greeting("Android") } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/templated/TemplatedApp.kt b/app/src/main/java/com/kdbrian/sage/Sage.kt similarity index 74% rename from app/src/main/java/com/kdbrian/templated/TemplatedApp.kt rename to app/src/main/java/com/kdbrian/sage/Sage.kt index 7d3258d..22e1836 100644 --- a/app/src/main/java/com/kdbrian/templated/TemplatedApp.kt +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -1,13 +1,13 @@ -package com.kdbrian.templated +package com.kdbrian.sage import android.app.Application -import com.kdbrian.templated.di.templateModules +import com.kdbrian.sage.di.templateModules import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import timber.log.Timber -class TemplatedApp : Application() { +class Sage : Application() { override fun onCreate() { super.onCreate() @@ -17,7 +17,7 @@ class TemplatedApp : Application() { startKoin { androidLogger() - androidContext(this@TemplatedApp) + androidContext(this@Sage) modules( templateModules, diff --git a/app/src/main/java/com/kdbrian/templated/di/templateModules.kt b/app/src/main/java/com/kdbrian/sage/di/templateModules.kt similarity index 65% rename from app/src/main/java/com/kdbrian/templated/di/templateModules.kt rename to app/src/main/java/com/kdbrian/sage/di/templateModules.kt index bd75208..9f09545 100644 --- a/app/src/main/java/com/kdbrian/templated/di/templateModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/templateModules.kt @@ -1,4 +1,4 @@ -package com.kdbrian.templated.di +package com.kdbrian.sage.di import org.koin.dsl.module diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt new file mode 100644 index 0000000..3c393bb --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt @@ -0,0 +1,40 @@ +package com.kdbrian.sage.ui.composables + +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.kdbrian.sage.R +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun CircularProfileAvatar(modifier: Modifier = Modifier) { + + Surface( + modifier = modifier + .requiredSize(50.dp), + shape = CircleShape + ) { + AsyncImage( + model = null, + placeholder = painterResource(R.drawable.current_premium), + contentDescription = null, + contentScale = ContentScale.Crop + ) + + } +} + +@Preview +@Composable +private fun CircularProfileAvatarPrev() { + SageTheme { + CircularProfileAvatar() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt new file mode 100644 index 0000000..e03b1f1 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -0,0 +1,72 @@ +package com.kdbrian.sage.ui.composables + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun RoundedInputField( + modifier: Modifier = Modifier, + fieldState: TextFieldState = rememberTextFieldState(), + onValueChange: (String) -> Unit = {}, + placeholder: String = "Placeholder", + leadingIcon: @Composable () -> Unit = {}, + keyBoardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + colors: TextFieldColors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.LightGray, + unfocusedPlaceholderColor = Color.White, + focusedPlaceholderColor = Color.White, + focusedContainerColor = Color.White, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black, + cursorColor = Color.Black, + unfocusedLeadingIconColor = Color.White, + focusedLeadingIconColor = Color.White, + ) +) { + + TextField( + value = fieldState.text.toString(), + onValueChange = onValueChange, + modifier = modifier, + shape = RoundedCornerShape(12.dp), + placeholder = { + Text(text = placeholder, style = MaterialTheme.typography.labelMedium) + }, + leadingIcon = leadingIcon, + colors = colors, + keyboardActions = keyBoardActions, + keyboardOptions = keyboardOptions + ) +} + +@Preview +@Composable +private fun RoundedInputFieldPrev() { + SageTheme { + RoundedInputField() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt new file mode 100644 index 0000000..240528b --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt @@ -0,0 +1,69 @@ +package com.kdbrian.sage.ui.composables + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun RowButton( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + shape: Shape = RoundedCornerShape(12.dp), + leadingIcon: @Composable () -> Unit = { + Text( + text = LoremIpsum(2).values.joinToString(), + style = MaterialTheme.typography.titleMedium + ) + }, + trailingIcon: @Composable () -> Unit = { + IconButton(onClick = {}) { + Icon(imageVector = Icons.Rounded.KeyboardArrowRight, contentDescription = null) + } + }, +) { + Surface( + modifier = modifier, + shape = shape, + color = color, + contentColor = contentColor + ) { + + Row( + modifier = Modifier + .padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + leadingIcon() + trailingIcon() + + } + + } +} + +@Preview +@Composable +private fun RowButtonPrev() { + SageTheme { + RowButton() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt new file mode 100644 index 0000000..45b9f37 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt @@ -0,0 +1,54 @@ +package com.kdbrian.sage.ui.composables + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun TopicCard( + modifier: Modifier = Modifier, + text: AnnotatedString = buildAnnotatedString { + append("Topic") + } +) { + + Surface( + modifier = modifier + .requiredSize(100.dp), + shape = RoundedCornerShape(24.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium + ) + } + + + } + +} + +@Preview +@Composable +private fun TopicCardPrev() { + SageTheme { + TopicCard() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt new file mode 100644 index 0000000..5ba8b9e --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -0,0 +1,56 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun CreateScreen(modifier: Modifier = Modifier) { + + Scaffold( + modifier = Modifier.fillMaxSize() + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(16.dp), + ) { + + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ){ + + Text(text = "Create", style = MaterialTheme.typography.headlineLarge) + + + } + } + + + } + + } +} + +@Preview +@Composable +private fun CreateScreenPrev() { + SageTheme { + CreateScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/templated/ui/theme/Color.kt b/app/src/main/java/com/kdbrian/sage/ui/theme/Color.kt similarity index 86% rename from app/src/main/java/com/kdbrian/templated/ui/theme/Color.kt rename to app/src/main/java/com/kdbrian/sage/ui/theme/Color.kt index 1c3e9e9..6f0f42c 100644 --- a/app/src/main/java/com/kdbrian/templated/ui/theme/Color.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package com.kdbrian.templated.ui.theme +package com.kdbrian.sage.ui.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/com/kdbrian/templated/ui/theme/Theme.kt b/app/src/main/java/com/kdbrian/sage/ui/theme/Theme.kt similarity index 94% rename from app/src/main/java/com/kdbrian/templated/ui/theme/Theme.kt rename to app/src/main/java/com/kdbrian/sage/ui/theme/Theme.kt index 025f156..70bd7cd 100644 --- a/app/src/main/java/com/kdbrian/templated/ui/theme/Theme.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/theme/Theme.kt @@ -1,6 +1,5 @@ -package com.kdbrian.templated.ui.theme +package com.kdbrian.sage.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -34,7 +33,7 @@ private val LightColorScheme = lightColorScheme( ) @Composable -fun TemplatedTheme( +fun SageTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, diff --git a/app/src/main/java/com/kdbrian/templated/ui/theme/Type.kt b/app/src/main/java/com/kdbrian/sage/ui/theme/Type.kt similarity index 96% rename from app/src/main/java/com/kdbrian/templated/ui/theme/Type.kt rename to app/src/main/java/com/kdbrian/sage/ui/theme/Type.kt index ec3bcb8..f06a312 100644 --- a/app/src/main/java/com/kdbrian/templated/ui/theme/Type.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/theme/Type.kt @@ -1,4 +1,4 @@ -package com.kdbrian.templated.ui.theme +package com.kdbrian.sage.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle diff --git a/app/src/main/java/com/kdbrian/templated/EnvironmentStatusDisplay.kt b/app/src/main/java/com/kdbrian/templated/EnvironmentStatusDisplay.kt deleted file mode 100644 index 16dff08..0000000 --- a/app/src/main/java/com/kdbrian/templated/EnvironmentStatusDisplay.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.kdbrian.templated - -import android.content.Context -import android.net.ConnectivityManager -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - - -@Composable -fun EnvironmentStatusDisplay( - isConnected: Boolean, - env: String = BuildConfig.ENV, - modifier: Modifier = Modifier -) { - Box(modifier = modifier.fillMaxSize()) { - - Card( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 16.dp), - elevation = CardDefaults.cardElevation(8.dp), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFF44336) - ) - ) { - Text( - text = if (isConnected) "Connected" else "No Internet", - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - color = Color.White - ) - } - - Card( - modifier = modifier.align(Alignment.Center), - elevation = CardDefaults.cardElevation(8.dp), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding( - horizontal = 12.dp, - vertical = 6.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = "Environment: $env", - style = MaterialTheme.typography.displayMedium.copy( - textAlign = TextAlign.Center - ) -// modifier = Modifier.padding(16.dp) - ) - - - Text( - text = BuildConfig.VERSION_NAME, - fontWeight = FontWeight.Light, - style = MaterialTheme.typography.bodyLarge - ) - } - } - } -} - - -@Preview(name = "Connected - DEV") -@Composable -fun PreviewConnectedDev() { - EnvironmentStatusDisplay(isConnected = true, env = "dev") -} - -@Preview(name = "Disconnected - STAGING") -@Composable -fun PreviewDisconnectedStaging() { - EnvironmentStatusDisplay(isConnected = false, env = "staging") -} - -@Preview(name = "Connected - PROD") -@Composable -fun PreviewConnectedProd() { - EnvironmentStatusDisplay(isConnected = true, env = "prod") -} - - -@Composable -fun rememberIsConnected(): Boolean { - val context = LocalContext.current - val connectivityManager = remember { - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - } - - val networkState = - rememberUpdatedState(newValue = connectivityManager.activeNetworkInfo?.isConnected == true) - - // For demo purposes; for production use NetworkCallback instead. - return networkState.value -} diff --git a/app/src/main/res/drawable-nodpi/current_premium.webp b/app/src/main/res/drawable-nodpi/current_premium.webp new file mode 100644 index 0000000000000000000000000000000000000000..a5f714d05d42ba95046dcb2320e1376b1697ed11 GIT binary patch literal 96000 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 literal 0 HcmV?d00001 diff --git a/app/src/test/java/com/kdbrian/templated/ExampleUnitTest.kt b/app/src/test/java/com/kdbrian/sage/ExampleUnitTest.kt similarity index 91% rename from app/src/test/java/com/kdbrian/templated/ExampleUnitTest.kt rename to app/src/test/java/com/kdbrian/sage/ExampleUnitTest.kt index 6ef47b3..117c1fa 100644 --- a/app/src/test/java/com/kdbrian/templated/ExampleUnitTest.kt +++ b/app/src/test/java/com/kdbrian/sage/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.kdbrian.templated +package com.kdbrian.sage import org.junit.Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b26523..3264171 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,9 @@ [versions] agp = "8.10.1" +coilCompose = "3.3.0" +coilNetworkOkhttp = "3.3.0" koinBom = "4.0.3" -kotlin = "2.0.21" +kotlin = "2.2.0" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -10,12 +12,15 @@ lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" composeBom = "2024.09.00" junitJupiter = "5.8.1" -junitJunit = "4.12" +navigationCompose = "2.9.3" timber = "5.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5702908..3b6bd34 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,5 +19,5 @@ dependencyResolutionManagement { } } -rootProject.name = "Templated" +rootProject.name = "Sage" include(":app") From bb5fa2336083af2039cf5eeec2a87b0e80537414 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 11:13:55 +0300 Subject: [PATCH 04/25] refactor/ - more components. --- .../sage/ui/composables/CategoryItem.kt | 62 +++++++++++++++++++ .../ui/composables/CircularProfileAvatar.kt | 11 +++- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt new file mode 100644 index 0000000..355af7f --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt @@ -0,0 +1,62 @@ +package com.kdbrian.sage.ui.composables + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun CategoryItem( + modifier: Modifier = Modifier, + text : String = LoremIpsum(2).values.joinToString(), + onClick: () -> Unit = {}, + shape: Shape = RoundedCornerShape(24.dp) +) { + + Surface( + modifier = modifier + .requiredWidthIn(min= 100.dp, max = 120.dp) + .requiredHeightIn(min = 30.dp, max = 35.dp), + shape = shape, + onClick = onClick, + shadowElevation = 3.dp + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold + ), + text = text + ) + } + + } +} + +@Preview +@Composable +private fun CategoryItemPrev() { + SageTheme { + CategoryItem() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt index 3c393bb..cd80f0f 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt @@ -14,16 +14,23 @@ import com.kdbrian.sage.R import com.kdbrian.sage.ui.theme.SageTheme @Composable -fun CircularProfileAvatar(modifier: Modifier = Modifier) { +fun CircularProfileAvatar( + modifier: Modifier = Modifier, + imageUri: String? = null, + onImageClick: () -> Unit = {} +) { Surface( + onClick = onImageClick, + shadowElevation = 3.dp, modifier = modifier .requiredSize(50.dp), shape = CircleShape ) { AsyncImage( - model = null, + model = imageUri, placeholder = painterResource(R.drawable.current_premium), + error = painterResource(R.drawable.current_premium), contentDescription = null, contentScale = ContentScale.Crop ) From 53c910ba75dd80d273f7ded0b7bd891c401b5856 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 11:50:54 +0300 Subject: [PATCH 05/25] refactor/ - rename app nin manifest. - add extended icons. - added a new util class for files utils/FileUtils. - update app name string --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 2 +- .../sage/ui/composables/CategoryItem.kt | 2 +- .../sage/ui/composables/RoundedInputField.kt | 5 +- .../kdbrian/sage/ui/composables/RowButton.kt | 16 +- .../kdbrian/sage/ui/screens/CreateScreen.kt | 137 ++++++++++++++++-- .../java/com/kdbrian/sage/util/FileUtils.kt | 27 ++++ app/src/main/res/values/strings.xml | 2 +- gradle/libs.versions.toml | 2 + 9 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/util/FileUtils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20b716b..3e00a9d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,4 +116,7 @@ dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) + //extended icons + implementation(libs.androidx.material.icons.extended) + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2607503..a6c427c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ Unit = {}, placeholder: String = "Placeholder", - leadingIcon: @Composable () -> Unit = {}, + leadingIcon: (@Composable () -> Unit)? = null, keyBoardActions: KeyboardActions = KeyboardActions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, colors: TextFieldColors = TextFieldDefaults.colors( @@ -52,6 +53,8 @@ fun RoundedInputField( value = fieldState.text.toString(), onValueChange = onValueChange, modifier = modifier, + singleLine = singleLine, + minLines = if(singleLine) 1 else 3, shape = RoundedCornerShape(12.dp), placeholder = { Text(text = placeholder, style = MaterialTheme.typography.labelMedium) diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt index 240528b..fa2776d 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt @@ -27,33 +27,29 @@ fun RowButton( color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = MaterialTheme.colorScheme.onSurface, shape: Shape = RoundedCornerShape(12.dp), - leadingIcon: @Composable () -> Unit = { + leadingIcon: (@Composable () -> Unit)? = { Text( text = LoremIpsum(2).values.joinToString(), style = MaterialTheme.typography.titleMedium ) }, - trailingIcon: @Composable () -> Unit = { - IconButton(onClick = {}) { - Icon(imageVector = Icons.Rounded.KeyboardArrowRight, contentDescription = null) - } - }, + trailingIcon: (@Composable () -> Unit)? =null, ) { Surface( modifier = modifier, shape = shape, color = color, + shadowElevation = 3.dp, contentColor = contentColor ) { - Row( modifier = Modifier - .padding(horizontal = 6.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - leadingIcon() - trailingIcon() + leadingIcon?.invoke() + trailingIcon?.invoke() } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt index 5ba8b9e..8c7baef 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -1,46 +1,161 @@ package com.kdbrian.sage.ui.screens +import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp +import com.kdbrian.sage.ui.composables.CategoryItem +import com.kdbrian.sage.ui.composables.RoundedInputField +import com.kdbrian.sage.ui.composables.RowButton import com.kdbrian.sage.ui.theme.SageTheme +import com.kdbrian.sage.util.FileUtils +import com.kdbrian.sage.util.FileUtils.getFileName @Composable fun CreateScreen(modifier: Modifier = Modifier) { + var docUri by remember { + mutableStateOf(Uri.EMPTY) + } + + val context = LocalContext.current + val iconAndText by remember { + derivedStateOf { + if (docUri != Uri.EMPTY && docUri != null) { + context.getFileName(docUri!!) to Icons.Rounded.Upload + } else { + "Select Document." to Icons.Rounded.Add + } + } + } + + + Scaffold( modifier = Modifier.fillMaxSize() ) { padding -> - LazyColumn( - modifier = Modifier.padding(padding), - contentPadding = PaddingValues(16.dp), + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ){ - - Text(text = "Create", style = MaterialTheme.typography.headlineLarge) + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + + Text(text = "Create", style = MaterialTheme.typography.headlineLarge) + Text( + text = LoremIpsum(12).values.joinToString(), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + ) + + } + + Spacer(Modifier.height(24.dp)) + + RowButton( + modifier = Modifier + .fillMaxWidth(), + leadingIcon = { + Row { + Icon( + imageVector = iconAndText.second, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = iconAndText.first ?: "Add Document", + style = MaterialTheme.typography.titleMedium + ) + } + }, + ) + + RoundedInputField( + modifier = Modifier + .fillMaxWidth(), + placeholder = "rename document" + ) + + RoundedInputField( + singleLine = false, + modifier = Modifier + .fillMaxWidth(), + placeholder = "Summary" + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "Topics", style = MaterialTheme.typography.titleLarge) + + CategoryItem(text = "Add") } + + } + LazyHorizontalGrid( + rows = androidx.compose.foundation.lazy.grid.GridCells.Fixed(3), + ) { + items(5) { + key(it) { + CategoryItem() + } + } + } } diff --git a/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt b/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt new file mode 100644 index 0000000..1402f9e --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt @@ -0,0 +1,27 @@ +package com.kdbrian.sage.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns + +object FileUtils { + + fun Context.getFileName(uri: Uri): String? { + var result: String? = null + if (uri.scheme == "content") { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + result = cursor.getString(nameIndex) + } + } + } + } + if (result == null) { + result = uri.path?.substringAfterLast('/') + } + return result + } + +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27bdf3c..f6b0be0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - Templated + Sage \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3264171..fad892f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,12 +12,14 @@ lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" composeBom = "2024.09.00" junitJupiter = "5.8.1" +materialIconsExtended = "1.7.8" navigationCompose = "2.9.3" timber = "5.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } From 80482e7c1cdbe736cbd3ea0443a047c4e380af52 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 12:20:00 +0300 Subject: [PATCH 06/25] refactor : up compose bom version to 25/7 from 24/9 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fad892f..beeac7a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" -composeBom = "2024.09.00" +composeBom = "2025.07.00" junitJupiter = "5.8.1" materialIconsExtended = "1.7.8" navigationCompose = "2.9.3" From 5777729a932a299e6af2dbe36788540dba9a155e Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 12:29:11 +0300 Subject: [PATCH 07/25] refactor/ - up some downgraded versions. - Create screen initial. - update MainActivity for edge to edge compatibility. - update RoundedInputField/onValueChange to be backward compatible. - Make text auto size in CategoryItem --- .../java/com/kdbrian/sage/MainActivity.kt | 30 ++-- .../sage/ui/composables/CategoryItem.kt | 19 ++- .../sage/ui/composables/RoundedInputField.kt | 8 +- .../kdbrian/sage/ui/composables/RowButton.kt | 2 + .../kdbrian/sage/ui/screens/CreateScreen.kt | 143 ++++++++++++++++-- gradle/libs.versions.toml | 4 +- 6 files changed, 162 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/kdbrian/sage/MainActivity.kt b/app/src/main/java/com/kdbrian/sage/MainActivity.kt index 1ca9e62..97732d6 100644 --- a/app/src/main/java/com/kdbrian/sage/MainActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -1,5 +1,6 @@ package com.kdbrian.sage +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -11,37 +12,30 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.core.view.WindowCompat import com.kdbrian.sage.ui.theme.SageTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + enableEdgeToEdge() + } else { + WindowCompat.setDecorFitsSystemWindows(window, false) + } + setContent { - SageTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + App { } } } } -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello ${BuildConfig.ENV}!", - modifier = modifier - ) -} -@Preview(showBackground = true) @Composable -fun GreetingPreview() { +fun App(content: @Composable () -> Unit) { SageTheme { - Greeting("Android") + content() } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt index 1309de6..8048ef1 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.requiredWidthIn import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -18,19 +20,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.kdbrian.sage.ui.theme.SageTheme @Composable fun CategoryItem( modifier: Modifier = Modifier, - text : String = LoremIpsum(2).values.joinToString(), + text: String = LoremIpsum(2).values.joinToString(), onClick: () -> Unit = {}, shape: Shape = RoundedCornerShape(24.dp) ) { Surface( modifier = modifier - .requiredWidthIn(min= 100.dp, max = 120.dp) + .requiredWidthIn(min = 100.dp, max = 120.dp) .requiredHeightIn(min = 30.dp, max = 35.dp), shape = shape, onClick = onClick, @@ -42,11 +45,17 @@ fun CategoryItem( .padding(horizontal = 12.dp), contentAlignment = Alignment.Center ) { - Text( - style = MaterialTheme.typography.labelLarge.copy( + BasicText( + style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.SemiBold ), - text = text + autoSize = TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = 16.sp, + step = 1.sp + ), + text = text, + modifier = Modifier ) } diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt index 815dd2e..c58e115 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -26,7 +26,7 @@ fun RoundedInputField( modifier: Modifier = Modifier, singleLine: Boolean = true, fieldState: TextFieldState = rememberTextFieldState(), - onValueChange: (String) -> Unit = {}, + onValueChange: ((String) -> Unit)? = null, placeholder: String = "Placeholder", leadingIcon: (@Composable () -> Unit)? = null, keyBoardActions: KeyboardActions = KeyboardActions.Default, @@ -51,10 +51,12 @@ fun RoundedInputField( TextField( value = fieldState.text.toString(), - onValueChange = onValueChange, + onValueChange = { + onValueChange?.invoke(it) ?: fieldState.setTextAndPlaceCursorAtEnd(it) + }, modifier = modifier, singleLine = singleLine, - minLines = if(singleLine) 1 else 3, + minLines = if (singleLine) 1 else 3, shape = RoundedCornerShape(12.dp), placeholder = { Text(text = placeholder, style = MaterialTheme.typography.labelMedium) diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt index fa2776d..4231396 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt @@ -34,10 +34,12 @@ fun RowButton( ) }, trailingIcon: (@Composable () -> Unit)? =null, + onClick: () -> Unit = {} ) { Surface( modifier = modifier, shape = shape, + onClick = onClick, color = color, shadowElevation = 3.dp, contentColor = contentColor diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt index 8c7baef..d950739 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -1,32 +1,36 @@ package com.kdbrian.sage.ui.screens +import android.content.Intent import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.* import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,17 +44,49 @@ import com.kdbrian.sage.ui.composables.CategoryItem import com.kdbrian.sage.ui.composables.RoundedInputField import com.kdbrian.sage.ui.composables.RowButton import com.kdbrian.sage.ui.theme.SageTheme -import com.kdbrian.sage.util.FileUtils import com.kdbrian.sage.util.FileUtils.getFileName +import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateScreen(modifier: Modifier = Modifier) { + val coroutineScope = rememberCoroutineScope() var docUri by remember { mutableStateOf(Uri.EMPTY) } - val context = LocalContext.current + + val bottomSheetState = rememberModalBottomSheetState() + var isAddingTopic by remember { + mutableStateOf(false) + } + + val topics = remember { + mutableStateListOf() + } + + LaunchedEffect(isAddingTopic) { + if (!isAddingTopic) { + bottomSheetState.hide() + } else { + bottomSheetState.show() + } + } + val snackbarHostState = remember { SnackbarHostState() } + + val pickDocument = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { + it?.let { + context.contentResolver.takePersistableUriPermission( + it, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + docUri = it + } + } + val iconAndText by remember { derivedStateOf { if (docUri != Uri.EMPTY && docUri != null) { @@ -64,13 +100,18 @@ fun CreateScreen(modifier: Modifier = Modifier) { Scaffold( + snackbarHost = { + androidx.compose.material3.SnackbarHost(hostState = snackbarHostState) + }, modifier = Modifier.fillMaxSize() ) { padding -> + val scrollState = rememberScrollState() Column( modifier = Modifier .padding(padding) .padding(16.dp) - .fillMaxSize(), + .fillMaxSize() + .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -110,6 +151,15 @@ fun CreateScreen(modifier: Modifier = Modifier) { ) } }, + onClick = { + pickDocument.launch( + arrayOf( + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + ) + } ) RoundedInputField( @@ -141,25 +191,86 @@ fun CreateScreen(modifier: Modifier = Modifier) { Text(text = "Topics", style = MaterialTheme.typography.titleLarge) - CategoryItem(text = "Add") + CategoryItem(text = "Add", onClick = { + if (docUri == Uri.EMPTY || docUri == null) { + coroutineScope.launch { + snackbarHostState.showSnackbar("Please select a document") + } + } else + isAddingTopic = !isAddingTopic + }) } } LazyHorizontalGrid( - rows = androidx.compose.foundation.lazy.grid.GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(4.dp), + rows = GridCells.Fixed(3), ) { - items(5) { - key(it) { - CategoryItem() + itemsIndexed(topics) { index, topic -> + key(index) { + CategoryItem( + text = topic, + modifier = Modifier.padding(4.dp) + ) } } } } + if (isAddingTopic) { + val topicName = rememberTextFieldState() + + ModalBottomSheet( + onDismissRequest = { + isAddingTopic = false + }, + sheetState = bottomSheetState + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + + Text( + text = "Add Topic", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 12.dp) + ) + + RoundedInputField( + fieldState = topicName, + modifier = Modifier.fillMaxWidth(), + placeholder = "Topic Name" + ) + + RowButton( + leadingIcon = { Text("Save") }, + modifier = Modifier.padding(top = 12.dp), + onClick = { + if (topics.size == 8) { + coroutineScope.launch { + snackbarHostState.showSnackbar("You can only add 8 topics.") + } + } else { + topics.add(topicName.text.trim().toString()) + } + isAddingTopic = false + } + ) + } + } + } + } + + } @Preview diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index beeac7a..59613e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,13 +2,13 @@ agp = "8.10.1" coilCompose = "3.3.0" coilNetworkOkhttp = "3.3.0" -koinBom = "4.0.3" +koinBom = "4.1.0" kotlin = "2.2.0" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" -lifecycleRuntimeKtx = "2.9.1" +lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" composeBom = "2025.07.00" junitJupiter = "5.8.1" From ff6e13bc5b223b78e00fd5368ab5679ccd6a4753 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 12:57:42 +0300 Subject: [PATCH 08/25] refactor/ - finished off profile screen --- .../sage/ui/composables/CategoryItem.kt | 16 +- .../ui/composables/CircularProfileAvatar.kt | 3 +- .../kdbrian/sage/ui/composables/RowButton.kt | 7 +- .../kdbrian/sage/ui/screens/ProfileScreen.kt | 317 ++++++++++++++++++ 4 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt index 8048ef1..f2cdb7e 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp @@ -26,6 +27,8 @@ import com.kdbrian.sage.ui.theme.SageTheme @Composable fun CategoryItem( modifier: Modifier = Modifier, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surface, + contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, text: String = LoremIpsum(2).values.joinToString(), onClick: () -> Unit = {}, shape: Shape = RoundedCornerShape(24.dp) @@ -36,6 +39,8 @@ fun CategoryItem( .requiredWidthIn(min = 100.dp, max = 120.dp) .requiredHeightIn(min = 30.dp, max = 35.dp), shape = shape, + color = color, + contentColor = contentColor, onClick = onClick, shadowElevation = 2.dp ) { @@ -47,15 +52,16 @@ fun CategoryItem( ) { BasicText( style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight(300), + textAlign = TextAlign.Center ), autoSize = TextAutoSize.StepBased( - minFontSize = 12.sp, - maxFontSize = 16.sp, - step = 1.sp + minFontSize = 14.sp, + maxFontSize = 18.sp, + stepSize = 1.sp ), text = text, - modifier = Modifier + modifier = Modifier.padding(4.dp) ) } diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt index cd80f0f..5eaf3a5 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt @@ -16,6 +16,7 @@ import com.kdbrian.sage.ui.theme.SageTheme @Composable fun CircularProfileAvatar( modifier: Modifier = Modifier, + size: Int = 50, imageUri: String? = null, onImageClick: () -> Unit = {} ) { @@ -24,7 +25,7 @@ fun CircularProfileAvatar( onClick = onImageClick, shadowElevation = 3.dp, modifier = modifier - .requiredSize(50.dp), + .requiredSize((size).dp), shape = CircleShape ) { AsyncImage( diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt index 4231396..4e42d40 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt @@ -2,6 +2,7 @@ package com.kdbrian.sage.ui.composables import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -23,7 +24,7 @@ import com.kdbrian.sage.ui.theme.SageTheme @Composable fun RowButton( - modifier: Modifier = Modifier, + modifier: Modifier = Modifier.fillMaxWidth(), color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = MaterialTheme.colorScheme.onSurface, shape: Shape = RoundedCornerShape(12.dp), @@ -37,7 +38,7 @@ fun RowButton( onClick: () -> Unit = {} ) { Surface( - modifier = modifier, + modifier = modifier.padding(4.dp), shape = shape, onClick = onClick, color = color, @@ -46,7 +47,7 @@ fun RowButton( ) { Row( modifier = Modifier - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt new file mode 100644 index 0000000..671bd69 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt @@ -0,0 +1,317 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.App +import com.kdbrian.sage.BuildConfig +import com.kdbrian.sage.ui.composables.CategoryItem +import com.kdbrian.sage.ui.composables.CircularProfileAvatar +import com.kdbrian.sage.ui.composables.RowButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen() { + Scaffold( + topBar = { + Surface( + shadowElevation = 2.dp + ) { + TopAppBar( + title = { Text(text = "Profile & Settings.") }, + navigationIcon = { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + } + ) + } + } + ) { + LazyColumn( + modifier = Modifier + .padding(it) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + + CircularProfileAvatar( + size = 100, + ) { } + + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.Start, + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + .padding(6.dp) + ) { + Text(text = "Joe Doe", style = MaterialTheme.typography.displaySmall) + Text( + text = "joedoe@dev.to", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Light + ) + ) + + CategoryItem( + color = Color(0xFFF7F0F0), + text = "Sage points", + modifier = Modifier.padding(top = 12.dp) + ) + } + } + } + + item { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(text = "Profile", style = MaterialTheme.typography.titleLarge) + + RowButton( + leadingIcon = { + Text( + "Activity", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon(imageVector = Icons.Rounded.Schedule, contentDescription = null) + } + ) { } + + RowButton( + leadingIcon = { + Text( + "Saved", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon(imageVector = Icons.Rounded.Bookmark, contentDescription = null) + } + ) { } + + RowButton( + leadingIcon = { + Text( + "Favourite", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon(imageVector = Icons.Rounded.Favorite, contentDescription = null) + } + ) { } + } + } + + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + shape = RoundedCornerShape(16.dp), + shadowElevation = 3.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Become A Sage Master", + style = MaterialTheme.typography.labelLarge + ) + + Icon( + imageVector = Icons.Rounded.Verified, + contentDescription = null, + tint = Color.Blue + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Sage Master", + style = MaterialTheme.typography.labelMedium + ) + + + Text( + text = "$0.30/hr", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Light + ) + ) + + + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "god Mod", + style = MaterialTheme.typography.labelMedium + ) + + + Text( + text = "$30", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Light + ) + ) + + + } + + } + } + + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + ) + + } + } + + + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Start), + text = "Preferences", style = MaterialTheme.typography.titleLarge + ) + + RowButton( + leadingIcon = { + Text( + "Notification settings", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Rounded.Notifications, + contentDescription = null + ) + } + ) { } + + + TextButton(onClick = {}) { + Text( + text = "Delete Account", + style = MaterialTheme.typography.labelMedium.copy( + color = Color.Red, + ) + ) + } + + TextButton(onClick = {}) { + Text( + text = "Terms & Conditions", + style = MaterialTheme.typography.labelMedium.copy( + color = Color.Blue + ) + ) + } + + Spacer(Modifier.weight(1f)) + Text( + text = "App ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.labelSmall + ) + Spacer(Modifier.weight(1f)) + + } + } + + + } + } + + +} + +@Preview +@Composable +private fun ProfileScreenPrev() { + App { + ProfileScreen() + } +} \ No newline at end of file From eb7b256802c949ef7a51b0b85f017e19229809ff Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 13:09:37 +0300 Subject: [PATCH 09/25] refactor/ - finished off profile screen --- .../sage/ui/composables/CategoryItem.kt | 17 ++++-- .../kdbrian/sage/ui/screens/ProfileScreen.kt | 52 ++++++++++++++++--- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt index f2cdb7e..28ad9d8 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt @@ -1,6 +1,8 @@ package com.kdbrian.sage.ui.composables +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn @@ -27,6 +29,7 @@ import com.kdbrian.sage.ui.theme.SageTheme @Composable fun CategoryItem( modifier: Modifier = Modifier, + icon: @Composable (() -> Unit)? = null, color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surface, contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, text: String = LoremIpsum(2).values.joinToString(), @@ -36,7 +39,7 @@ fun CategoryItem( Surface( modifier = modifier - .requiredWidthIn(min = 100.dp, max = 120.dp) + .requiredWidthIn(min = 100.dp, max = 150.dp) .requiredHeightIn(min = 30.dp, max = 35.dp), shape = shape, color = color, @@ -44,12 +47,16 @@ fun CategoryItem( onClick = onClick, shadowElevation = 2.dp ) { - Box( + + Row( modifier = Modifier .fillMaxSize() - .padding(horizontal = 12.dp), - contentAlignment = Alignment.Center + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { + icon?.invoke() + BasicText( style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight(300), @@ -57,7 +64,7 @@ fun CategoryItem( ), autoSize = TextAutoSize.StepBased( minFontSize = 14.sp, - maxFontSize = 18.sp, + maxFontSize = 16.sp, stepSize = 1.sp ), text = text, diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt index 671bd69..b2d3c82 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt @@ -1,17 +1,22 @@ package com.kdbrian.sage.ui.screens +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Verified @@ -63,12 +68,15 @@ fun ProfileScreen() { LazyColumn( modifier = Modifier .padding(it) - .padding(16.dp), + .padding(16.dp) + , verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { Row( + modifier = Modifier + , verticalAlignment = Alignment.CenterVertically, ) { @@ -92,11 +100,41 @@ fun ProfileScreen() { ) ) - CategoryItem( - color = Color(0xFFF7F0F0), - text = "Sage points", + + val scrollState = rememberScrollState() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 12.dp) - ) + .horizontalScroll(scrollState) + ){ + + CategoryItem( + icon = { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + modifier = Modifier.size(25.dp) + ) + }, + color = Color(0xFFF7F0F0), + text = "Sage points", + modifier = Modifier + ) + + CategoryItem( + icon = { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + modifier = Modifier.size(25.dp) + ) + }, + color = Color(0xFFF7F0F0), + text = "Profile Tune", + modifier = Modifier + ) + } } } } @@ -179,7 +217,9 @@ fun ProfileScreen() { ) { Text( text = "Become A Sage Master", - style = MaterialTheme.typography.labelLarge + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold + ) ) Icon( From deaeb9e56f9b31dd69f2099d80ff28bdba8ba69d Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 13:48:39 +0300 Subject: [PATCH 10/25] refactor/ - finished off HomeScreen UI impl --- .../kdbrian/sage/ui/composables/TopicCard.kt | 5 + .../com/kdbrian/sage/ui/screens/HomeScreen.kt | 248 ++++++++++++++++++ .../res/drawable-nodpi/paper_avalanche.jpg | Bin 0 -> 22312 bytes 3 files changed, 253 insertions(+) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt create mode 100644 app/src/main/res/drawable-nodpi/paper_avalanche.jpg diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt index 45b9f37..802c01f 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt @@ -20,12 +20,17 @@ import com.kdbrian.sage.ui.theme.SageTheme @Composable fun TopicCard( modifier: Modifier = Modifier, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surface, + contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, text: AnnotatedString = buildAnnotatedString { append("Topic") } ) { Surface( + color = color, + contentColor = contentColor, + shadowElevation = 2.dp, modifier = modifier .requiredSize(100.dp), shape = RoundedCornerShape(24.dp) diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..b6686db --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -0,0 +1,248 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.kdbrian.sage.R +import com.kdbrian.sage.ui.composables.CategoryItem +import com.kdbrian.sage.ui.composables.CircularProfileAvatar +import com.kdbrian.sage.ui.composables.RoundedInputField +import com.kdbrian.sage.ui.composables.TopicCard +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun HomeScreen() { + + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(4.dp) + ) { + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + + CircularProfileAvatar { } + + Spacer(Modifier.padding(12.dp)) + + RoundedInputField( + placeholder = "Search", + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null + ) + } + ) + + } + + /*val scrollState = rememberScrollState() + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .padding(horizontal = 12.dp) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CategoryItem(text = "Music") + + CategoryItem(text = "Science") + + CategoryItem(text = "Finance") + + CategoryItem(text = "Lifestyle") + + CategoryItem(text = "Sports") + + }*/ + + } + } + }, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + .padding(12.dp) + ) { + + Text( + text = "Explore", + style = MaterialTheme.typography.titleLarge + ) + + LazyHorizontalGrid( + rows = GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .height(330.dp), + ) { + items(12) { + TopicCard( + modifier = Modifier.padding(4.dp), + text = buildAnnotatedString { append("Topic $it") } + ) + } + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = "Documents For You", + style = MaterialTheme.typography.titleLarge + ) + + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .requiredHeightIn( + max = 200.dp, + min = 180.dp + ), + contentColor = Color.White, + shape = RoundedCornerShape(24.dp), + shadowElevation = 3.dp, + ) { + + AsyncImage( + contentScale = ContentScale.Crop, + contentDescription = null, + model = null, + error = painterResource(R.drawable.paper_avalanche), + modifier = Modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black//.copy(alpha = 0.85f) + ) + ) + ), + content = {}, + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.Start, + ) { + Spacer(Modifier.weight(1f)) + + Text( + text = "For You", + style = MaterialTheme.typography.displaySmall + ) + + + Text( + text = "A collection of what you may like. We keep it updated with every new document you like", + style = MaterialTheme.typography.labelMedium.copy( + textAlign = TextAlign.Start, + fontWeight = FontWeight.Light + ) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + CategoryItem( + text = "explore", + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + } + ) + + TextButton(onClick = {}) { + Text(text = "Shared Documents") + } + } + + + } + + + } + + } + + + } + +} + +@Preview +@Composable +private fun HomeScreenPrev() { + SageTheme { + HomeScreen() + } +} diff --git a/app/src/main/res/drawable-nodpi/paper_avalanche.jpg b/app/src/main/res/drawable-nodpi/paper_avalanche.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bbe912fc567677c21159f294ec8b3713ec78bd98 GIT binary patch literal 22312 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 literal 0 HcmV?d00001 From ec2ac352a08263376f42c5eb0b37fe2040381dfc Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 15:40:27 +0300 Subject: [PATCH 11/25] refactor/ - details screen impl --- .../java/com/kdbrian/sage/MainActivity.kt | 6 - .../sage/ui/composables/CategoryItem.kt | 3 - .../sage/ui/composables/ListItemPreview.kt | 98 +++++++ .../sage/ui/composables/RoundedInputField.kt | 3 - .../kdbrian/sage/ui/composables/RowButton.kt | 4 - .../kdbrian/sage/ui/composables/TopicCard.kt | 1 - .../kdbrian/sage/ui/screens/CreateScreen.kt | 9 +- .../sage/ui/screens/DocumentDetails.kt | 243 ++++++++++++++++++ .../com/kdbrian/sage/ui/screens/HomeScreen.kt | 6 - .../kdbrian/sage/ui/screens/TopicDetails.kt | 142 ++++++++++ ...24dp_1f1f1f_fill0_wght400_grad0_opsz24.xml | 9 + 11 files changed, 500 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt create mode 100644 app/src/main/res/drawable-nodpi/stars_2_24dp_1f1f1f_fill0_wght400_grad0_opsz24.xml diff --git a/app/src/main/java/com/kdbrian/sage/MainActivity.kt b/app/src/main/java/com/kdbrian/sage/MainActivity.kt index 97732d6..7af636a 100644 --- a/app/src/main/java/com/kdbrian/sage/MainActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -5,13 +5,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.core.view.WindowCompat import com.kdbrian.sage.ui.theme.SageTheme diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt index 28ad9d8..bf666d2 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt @@ -1,19 +1,16 @@ package com.kdbrian.sage.ui.composables import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.requiredWidthIn -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt new file mode 100644 index 0000000..2616505 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt @@ -0,0 +1,98 @@ +package com.kdbrian.sage.ui.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.App + +@Composable +fun ListItemPreview( + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .padding(4.dp), + shape = RoundedCornerShape(16.dp), + ) { + Row( + modifier = Modifier.padding(4.dp), + verticalAlignment = Alignment.Top + ) { + + Box( + modifier = Modifier + .size(70.dp) + .background(color = Color.LightGray, shape = RoundedCornerShape(12.dp)) + ) + + Spacer(Modifier.padding(6.dp)) + + Column( + horizontalAlignment = Alignment.Start, + + ) { + + Text( + text = LoremIpsum(3).values.joinToString(), + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = buildAnnotatedString { + append("Upto ") + withStyle( + SpanStyle( + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.SemiBold + ) + ) { + append("300 ") + } + append("documents.") + }, + style = MaterialTheme.typography.labelMedium + ) + } + + } + + + } + +} + +@Preview +@Composable +private fun TopicItemPreviewPrev() { + App { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + ListItemPreview() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt index c58e115..9e0f3f0 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -6,9 +6,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt index 4e42d40..89aa6f3 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt @@ -5,10 +5,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.KeyboardArrowRight -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt index 802c01f..152b9a3 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt @@ -3,7 +3,6 @@ package com.kdbrian.sage.ui.composables import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt index d950739..52cc0ae 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -29,8 +29,15 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt new file mode 100644 index 0000000..4427143 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt @@ -0,0 +1,243 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Copyright +import androidx.compose.material.icons.rounded.FastForward +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.App +import com.kdbrian.sage.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentDetails( + +) { + + var isFavorite by remember { + mutableStateOf(false) + } + val favColor by animateColorAsState( + if (isFavorite) + Color.Red + else + Color.Black + ) + + val size = animateDpAsState( + if (isFavorite) + 25.dp + else + 18.dp + ) + + + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp + ) { + TopAppBar( + title = { + Column { + Text(text = LoremIpsum(2).values.joinToString()) + } + }, + navigationIcon = { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + IconButton(onClick = { + isFavorite = !isFavorite + }) { + Icon( + tint = favColor, + imageVector = Icons.Rounded.Favorite, + contentDescription = null, + modifier = Modifier.size(size.value) + ) + } + }, + ) + } + }) { + Column( + modifier = Modifier + .padding(it) + .padding(16.dp) + ) { + + + Text( + text = LoremIpsum(3).values.joinToString(), + style = MaterialTheme.typography.displayMedium + ) + + Spacer(Modifier.padding(12.dp)) + + Text( + text = LoremIpsum(24).values.joinToString(), + style = MaterialTheme.typography.bodyLarge + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + shape = RoundedCornerShape(12.dp), + shadowElevation = 2.dp + ) { + + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + + Text( + text = "Rights", style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold + ) + ) + + Icon( + imageVector = Icons.Rounded.Copyright, + modifier = Modifier.size(25.dp), + contentDescription = null + ) + + } + + Text( + text = LoremIpsum(3).values.joinToString(), + style = MaterialTheme.typography.bodyMedium + ) + + + Text( + text = LoremIpsum(3).values.joinToString(), + style = MaterialTheme.typography.bodyMedium + ) + + } + } + + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + shape = RoundedCornerShape(16.dp), + shadowElevation = 5.dp + ) { + + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + + Text( + text = "Summary", style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold + ) + ) + + Icon( + painter = painterResource(R.drawable.stars_2_24dp_1f1f1f_fill0_wght400_grad0_opsz24), + modifier = Modifier.size(25.dp), + contentDescription = null + ) + + } + + Text( + text = LoremIpsum(18).values.joinToString(), + style = MaterialTheme.typography.bodyMedium + ) + + + IconButton( + modifier = Modifier.align(Alignment.End), onClick = {}) { + Icon( + imageVector = Icons.Rounded.FastForward, + contentDescription = null, + ) + } + + } + } + + Spacer(Modifier.weight(1f)) + + + TextButton(onClick = {}, modifier = Modifier.align(Alignment.CenterHorizontally)) { + Row { + Text(text = "Share") + } + } + + Spacer(Modifier.weight(1f)) + + + } + } + +} + +@Preview +@Composable +private fun DocumentDetailsPrev() { + App { + DocumentDetails() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt index b6686db..95146e2 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -18,17 +18,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.DrawerValue import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,7 +35,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.kdbrian.sage.R diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt new file mode 100644 index 0000000..3b2e2d9 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt @@ -0,0 +1,142 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kdbrian.sage.App +import com.kdbrian.sage.ui.composables.ListItemPreview + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopicDetails() { + + + var isFavorite by remember { + mutableStateOf(false) + } + + val icon = remember { + derivedStateOf { + if (isFavorite) + Icons.Rounded.Favorite + else + Icons.Rounded.FavoriteBorder + } + } + + + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp + ) { + CenterAlignedTopAppBar( + title = { + Text( + text = "Topic Details" + ) + }, + navigationIcon = { + IconButton(onClick = {}) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + val tint by animateColorAsState( + if (isFavorite) + Color.Red + else + Color.Black + ) + + + IconButton(onClick = { + isFavorite = !isFavorite + }) { + Icon( + tint = tint, + imageVector = icon.value, + contentDescription = null + ) + } + } + ) + } + } + ) { + LazyColumn( + modifier = Modifier + .padding(it) + .padding(12.dp) + ) { + items(5) { + ListItemPreview() + } + + item { + Box( + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + HorizontalDivider( + modifier = Modifier.padding( + horizontal = 24.dp, + vertical = 6.dp + ) + ) + + TextButton(onClick = {}) { + Text(text = "More", style = MaterialTheme.typography.titleMedium) + } + + } + } + } + } + } + + +} + +@Preview +@Composable +private fun TopicDetailsPrev() { + App { + TopicDetails() + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/stars_2_24dp_1f1f1f_fill0_wght400_grad0_opsz24.xml b/app/src/main/res/drawable-nodpi/stars_2_24dp_1f1f1f_fill0_wght400_grad0_opsz24.xml new file mode 100644 index 0000000..9900c6a --- /dev/null +++ b/app/src/main/res/drawable-nodpi/stars_2_24dp_1f1f1f_fill0_wght400_grad0_opsz24.xml @@ -0,0 +1,9 @@ + + + From 22c7db0e2f0be9ee7ca630d338c7fa4d3c1482c9 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 15:54:38 +0300 Subject: [PATCH 12/25] refactor/ - proceeding to nav --- app/build.gradle.kts | 4 ++++ .../main/java/com/kdbrian/sage/nav/MainNav.kt | 22 +++++++++++++++++++ .../main/java/com/kdbrian/sage/nav/Route.kt | 6 +++++ gradle/libs.versions.toml | 2 ++ 4 files changed, 34 insertions(+) create mode 100644 app/src/main/java/com/kdbrian/sage/nav/MainNav.kt create mode 100644 app/src/main/java/com/kdbrian/sage/nav/Route.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3e00a9d..03973e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,6 +16,8 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") + kotlin("plugin.serialization") version "2.2.0" + } android { @@ -119,4 +121,6 @@ dependencies { //extended icons implementation(libs.androidx.material.icons.extended) + //serialization + implementation(libs.kotlinx.serialization.json) } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt new file mode 100644 index 0000000..4989b98 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -0,0 +1,22 @@ +package com.kdbrian.sage.nav + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController + +@Composable +fun MainNav( + navHostController: NavHostController = rememberNavController() +) { + + NavHost(navController = navHostController, startDestination = "home") { + + + + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/nav/Route.kt b/app/src/main/java/com/kdbrian/sage/nav/Route.kt new file mode 100644 index 0000000..f464483 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/nav/Route.kt @@ -0,0 +1,6 @@ +package com.kdbrian.sage.nav + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Route \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59613e9..b3d5b7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" +kotlinxSerializationJson = "1.9.0" lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" composeBom = "2025.07.00" @@ -37,6 +38,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junitJupiter" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koinBom" } koin-core = { module = "io.insert-koin:koin-core" } From c3e1cb005b0452a2abec2fd4719ec0cfec0d5101 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 17:11:47 +0300 Subject: [PATCH 13/25] refactor/ - nav impl done. - currently I can move into and across all screens. - started UDF pattern. --- .../java/com/kdbrian/sage/MainActivity.kt | 2 + .../main/java/com/kdbrian/sage/nav/MainNav.kt | 56 +++- .../main/java/com/kdbrian/sage/nav/Route.kt | 23 +- .../kdbrian/sage/ui/composables/TopicCard.kt | 4 +- .../kdbrian/sage/ui/screens/CreateScreen.kt | 18 +- .../sage/ui/screens/DocumentDetails.kt | 11 +- .../com/kdbrian/sage/ui/screens/HomeScreen.kt | 245 ++++-------------- .../kdbrian/sage/ui/screens/LandingPage.kt | 232 +++++++++++++++++ .../kdbrian/sage/ui/screens/ProfileScreen.kt | 11 +- .../kdbrian/sage/ui/screens/TopicDetails.kt | 22 +- .../com/kdbrian/sage/ui/util/BottomBarItem.kt | 30 +++ 11 files changed, 432 insertions(+), 222 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/util/BottomBarItem.kt diff --git a/app/src/main/java/com/kdbrian/sage/MainActivity.kt b/app/src/main/java/com/kdbrian/sage/MainActivity.kt index 7af636a..04edd3a 100644 --- a/app/src/main/java/com/kdbrian/sage/MainActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.core.view.WindowCompat +import com.kdbrian.sage.nav.MainNav import com.kdbrian.sage.ui.theme.SageTheme class MainActivity : ComponentActivity() { @@ -21,6 +22,7 @@ class MainActivity : ComponentActivity() { setContent { App { + MainNav() } } } diff --git a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt index 4989b98..e245f98 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -1,20 +1,56 @@ package com.kdbrian.sage.nav import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.kdbrian.sage.ui.screens.DocumentDetails +import com.kdbrian.sage.ui.screens.HomeScreen +import com.kdbrian.sage.ui.screens.ProfileScreen +import com.kdbrian.sage.ui.screens.TopicDetails @Composable -fun MainNav( - navHostController: NavHostController = rememberNavController() -) { - - NavHost(navController = navHostController, startDestination = "home") { - - - +fun MainNav() { + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = HomeScreenRoute) { + + composable { + HomeScreen( + onOpenProfile = { + navController.navigate(ProfileRoute) + }, + onTopicExpand = { + navController.navigate(TopicDetailsRoute) + }, + onOpenFYP = { + } + ) + } + + + composable { + TopicDetails( + navController = navController, + onClose = {navController.popBackStack()} + ) + } + + composable { + ProfileScreen( + navHostController = navController + ) + } + + composable { + val detailsRoute = it.toRoute() + DocumentDetails( + navHostController = navController, + documentId = detailsRoute.docId + + ) + } } diff --git a/app/src/main/java/com/kdbrian/sage/nav/Route.kt b/app/src/main/java/com/kdbrian/sage/nav/Route.kt index f464483..48b44b8 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/Route.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/Route.kt @@ -3,4 +3,25 @@ package com.kdbrian.sage.nav import kotlinx.serialization.Serializable @Serializable -sealed class Route \ No newline at end of file +sealed class Route + + +@Serializable +data object HomeScreenRoute : Route() + + +@Serializable +data object CreateScreenRoute : Route() + + +@Serializable +data object TopicDetailsRoute : Route() + + +@Serializable +data object ProfileRoute : Route() + + +@Serializable +data class DocumentDetailsRoute(val docId : String): Route() + diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt index 152b9a3..8fef508 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt @@ -23,10 +23,12 @@ fun TopicCard( contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, text: AnnotatedString = buildAnnotatedString { append("Topic") - } + }, + onClick: () -> Unit = {} ) { Surface( + onClick = onClick, color = color, contentColor = contentColor, shadowElevation = 2.dp, diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt index 52cc0ae..a8d2e0f 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -56,7 +56,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateScreen(modifier: Modifier = Modifier) { +fun CreateScreen() { val coroutineScope = rememberCoroutineScope() var docUri by remember { @@ -115,7 +115,6 @@ fun CreateScreen(modifier: Modifier = Modifier) { val scrollState = rememberScrollState() Column( modifier = Modifier - .padding(padding) .padding(16.dp) .fillMaxSize() .verticalScroll(scrollState), @@ -228,6 +227,18 @@ fun CreateScreen(modifier: Modifier = Modifier) { } } + RowButton( + modifier = Modifier.align(Alignment.CenterHorizontally), + leadingIcon = { + Text( + text = "Save", + style = MaterialTheme.typography.titleMedium + ) + } + ) { + + } + } if (isAddingTopic) { @@ -266,7 +277,8 @@ fun CreateScreen(modifier: Modifier = Modifier) { snackbarHostState.showSnackbar("You can only add 8 topics.") } } else { - topics.add(topicName.text.trim().toString()) + if (!topics.contains(topicName.text.trim().toString())) + topics.add(topicName.text.trim().toString()) } isAddingTopic = false } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt index 4427143..c0c31ed 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt @@ -37,18 +37,23 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController import com.kdbrian.sage.App import com.kdbrian.sage.R +import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable fun DocumentDetails( - + navHostController: NavHostController = rememberNavController(), + documentId: String = UUID.randomUUID().toString() ) { var isFavorite by remember { mutableStateOf(false) } + val favColor by animateColorAsState( if (isFavorite) Color.Red @@ -76,7 +81,9 @@ fun DocumentDetails( } }, navigationIcon = { - IconButton(onClick = {}) { + IconButton(onClick = { + navHostController.popBackStack() + }) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt index 95146e2..91480de 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -1,231 +1,80 @@ package com.kdbrian.sage.ui.screens -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeightIn -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.kdbrian.sage.R -import com.kdbrian.sage.ui.composables.CategoryItem +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.kdbrian.sage.nav.CreateScreenRoute +import com.kdbrian.sage.nav.HomeScreenRoute import com.kdbrian.sage.ui.composables.CircularProfileAvatar import com.kdbrian.sage.ui.composables.RoundedInputField -import com.kdbrian.sage.ui.composables.TopicCard import com.kdbrian.sage.ui.theme.SageTheme +import com.kdbrian.sage.ui.util.BottomBarItem @Composable -fun HomeScreen() { +fun HomeScreen( + navHostController: NavHostController = rememberNavController(), + onOpenProfile : ()-> Unit, + onTopicExpand : ()-> Unit, + onOpenFYP: () -> Unit = {} +) { - Scaffold( - topBar = { - Surface( - shadowElevation = 3.dp, - ) { - Column( - modifier = Modifier.padding(4.dp) - ) { - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - - CircularProfileAvatar { } - - Spacer(Modifier.padding(12.dp)) - - RoundedInputField( - placeholder = "Search", - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Search, - contentDescription = null - ) - } - ) - - } - - /*val scrollState = rememberScrollState() - Row( - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - .padding(horizontal = 12.dp) - .horizontalScroll(scrollState), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - CategoryItem(text = "Music") - - CategoryItem(text = "Science") - - CategoryItem(text = "Finance") - - CategoryItem(text = "Lifestyle") - - CategoryItem(text = "Sports") - - }*/ + val backStackEntryAsState = navHostController.currentBackStackEntryAsState() + Scaffold( + bottomBar = { + NavigationBar { + BottomBarItem.twoScreenItems.forEachIndexed { index, item -> + NavigationBarItem( + selected = backStackEntryAsState.value?.destination?.route == item.route.toString(), + onClick = { + navHostController.navigate(item.route) + }, + icon = { + Icon(imageVector = item.icon, contentDescription = null) + }, + label = { + Text(text = item.label) + } + ) } } - }, + } ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(it) - .padding(12.dp) + NavHost( + modifier = Modifier.padding(it), + navController = navHostController, + startDestination = HomeScreenRoute ) { - - Text( - text = "Explore", - style = MaterialTheme.typography.titleLarge - ) - - LazyHorizontalGrid( - rows = GridCells.Fixed(3), - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .height(330.dp), - ) { - items(12) { - TopicCard( - modifier = Modifier.padding(4.dp), - text = buildAnnotatedString { append("Topic $it") } - ) - } - } - - Spacer(Modifier.height(24.dp)) - - Text( - text = "Documents For You", - style = MaterialTheme.typography.titleLarge - ) - - - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .requiredHeightIn( - max = 200.dp, - min = 180.dp - ), - contentColor = Color.White, - shape = RoundedCornerShape(24.dp), - shadowElevation = 3.dp, - ) { - - AsyncImage( - contentScale = ContentScale.Crop, - contentDescription = null, - model = null, - error = painterResource(R.drawable.paper_avalanche), - modifier = Modifier.fillMaxSize() - ) - - Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black//.copy(alpha = 0.85f) - ) - ) - ), - content = {}, + composable { + LandingPage( + onOpenProfile = onOpenProfile, + onOpenTopic = onTopicExpand, + openFYP = onOpenFYP ) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - horizontalAlignment = Alignment.Start, - ) { - Spacer(Modifier.weight(1f)) - - Text( - text = "For You", - style = MaterialTheme.typography.displaySmall - ) - - - Text( - text = "A collection of what you may like. We keep it updated with every new document you like", - style = MaterialTheme.typography.labelMedium.copy( - textAlign = TextAlign.Start, - fontWeight = FontWeight.Light - ) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - CategoryItem( - text = "explore", - icon = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = null, - modifier = Modifier.size(15.dp) - ) - } - ) - - TextButton(onClick = {}) { - Text(text = "Shared Documents") - } - } - - - } - - } - + composable { + CreateScreen() + } } @@ -237,6 +86,6 @@ fun HomeScreen() { @Composable private fun HomeScreenPrev() { SageTheme { - HomeScreen() +// HomeScreen() } } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt new file mode 100644 index 0000000..4c609ca --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt @@ -0,0 +1,232 @@ +package com.kdbrian.sage.ui.screens + +import android.icu.text.StringSearch +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.kdbrian.sage.R +import com.kdbrian.sage.ui.composables.CategoryItem +import com.kdbrian.sage.ui.composables.CircularProfileAvatar +import com.kdbrian.sage.ui.composables.RoundedInputField +import com.kdbrian.sage.ui.composables.TopicCard + +@Composable +fun LandingPage( + onOpenProfile: () -> Unit = {}, + onOpenTopic: () -> Unit = {}, + openFYP: () -> Unit = {} +) { + Column( + modifier = Modifier.Companion + .fillMaxSize() + ) { + + Column( + modifier = Modifier.padding(4.dp) + ) { + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + + CircularProfileAvatar( + onImageClick = onOpenProfile + ) + + Spacer(Modifier.padding(12.dp)) + + RoundedInputField( + placeholder = "Search", + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null + ) + } + ) + + } + + /*val scrollState = rememberScrollState() + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .padding(horizontal = 12.dp) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CategoryItem(text = "Music") + + CategoryItem(text = "Science") + + CategoryItem(text = "Finance") + + CategoryItem(text = "Lifestyle") + + CategoryItem(text = "Sports") + + }*/ + + } + + + Text( + text = "Explore", + modifier = Modifier.padding(top = 12.dp, start = 12.dp), + style = MaterialTheme.typography.titleLarge + ) + + LazyHorizontalGrid( + rows = GridCells.Fixed(3), + modifier = Modifier.Companion + .fillMaxWidth() + .padding(top = 12.dp) + .padding(horizontal = 12.dp) + .height(330.dp), + ) { + items(12) { + TopicCard( + modifier = Modifier.Companion.padding(4.dp), + text = buildAnnotatedString { append("Topic $it") }, + onClick = onOpenTopic + ) + } + } + + Spacer(Modifier.Companion.height(24.dp)) + + Text( + text = "Documents For You", + modifier = Modifier.padding(top = 12.dp, start = 12.dp), + style = MaterialTheme.typography.titleLarge + ) + + + Surface( + modifier = Modifier.Companion + .fillMaxWidth() + .padding(top = 12.dp) + .padding(horizontal = 12.dp) + .requiredHeightIn( + max = 200.dp, + min = 180.dp + ), + contentColor = Color.Companion.White, + shape = RoundedCornerShape(24.dp), + shadowElevation = 3.dp, + ) { + + AsyncImage( + contentScale = ContentScale.Companion.Crop, + contentDescription = null, + model = null, + error = painterResource(R.drawable.paper_avalanche), + modifier = Modifier.Companion.fillMaxSize() + ) + + Box( + modifier = Modifier.Companion + .fillMaxSize() + .background( + brush = Brush.Companion.verticalGradient( + colors = listOf( + Color.Companion.Transparent, + Color.Companion.Black//.copy(alpha = 0.85f) + ) + ) + ), + content = {}, + ) + + Column( + modifier = Modifier.Companion + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.Companion.Start, + ) { + Spacer(Modifier.Companion.weight(1f)) + + Text( + text = "For You", + style = MaterialTheme.typography.displaySmall + ) + + + Text( + text = "A collection of what you may like. We keep it updated with every new document you like", + style = MaterialTheme.typography.labelMedium.copy( + textAlign = TextAlign.Companion.Start, + fontWeight = FontWeight.Companion.Light + ) + ) + + Row( + verticalAlignment = Alignment.Companion.CenterVertically, + modifier = Modifier.Companion + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + CategoryItem( + text = "explore", + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = null, + modifier = Modifier.Companion.size(15.dp) + ) + }, + onClick = openFYP + ) + + TextButton(onClick = {}) { + Text(text = "Shared Documents") + } + } + + + } + + + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt index b2d3c82..971beb2 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController import com.kdbrian.sage.App import com.kdbrian.sage.BuildConfig import com.kdbrian.sage.ui.composables.CategoryItem @@ -45,7 +46,9 @@ import com.kdbrian.sage.ui.composables.RowButton @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProfileScreen() { +fun ProfileScreen( + navHostController: NavHostController +) { Scaffold( topBar = { Surface( @@ -54,7 +57,9 @@ fun ProfileScreen() { TopAppBar( title = { Text(text = "Profile & Settings.") }, navigationIcon = { - IconButton(onClick = {}) { + IconButton(onClick = { + navHostController.popBackStack() + }) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null @@ -352,6 +357,6 @@ fun ProfileScreen() { @Composable private fun ProfileScreenPrev() { App { - ProfileScreen() +// ProfileScreen() } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt index 3b2e2d9..3d0d553 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt @@ -31,12 +31,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController import com.kdbrian.sage.App +import com.kdbrian.sage.nav.DocumentDetailsRoute import com.kdbrian.sage.ui.composables.ListItemPreview +import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TopicDetails() { +fun TopicDetails( + navController: NavHostController, + onClose: () -> Unit = {} +) { var isFavorite by remember { @@ -65,7 +71,7 @@ fun TopicDetails() { ) }, navigationIcon = { - IconButton(onClick = {}) { + IconButton(onClick = onClose) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null @@ -101,7 +107,15 @@ fun TopicDetails() { .padding(12.dp) ) { items(5) { - ListItemPreview() + ListItemPreview( + onClick = { + navController.navigate( + DocumentDetailsRoute( + UUID.randomUUID().toString().split("-").first() + ) + ) + } + ) } item { @@ -137,6 +151,6 @@ fun TopicDetails() { @Composable private fun TopicDetailsPrev() { App { - TopicDetails() +// TopicDetails() } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/util/BottomBarItem.kt b/app/src/main/java/com/kdbrian/sage/ui/util/BottomBarItem.kt new file mode 100644 index 0000000..d9759a5 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/util/BottomBarItem.kt @@ -0,0 +1,30 @@ +package com.kdbrian.sage.ui.util + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Home +import androidx.compose.ui.graphics.vector.ImageVector +import com.kdbrian.sage.nav.CreateScreenRoute +import com.kdbrian.sage.nav.HomeScreenRoute +import com.kdbrian.sage.nav.Route + +data class BottomBarItem( + val label: String, + val icon: ImageVector, + val route: Route, +) { + companion object { + val twoScreenItems = listOf( + BottomBarItem( + label = "Home", + icon = Icons.Rounded.Home, + route = HomeScreenRoute + ), + BottomBarItem( + label = "Create", + icon = Icons.Rounded.Add, + route = CreateScreenRoute + ) + ) + } +} From 985c93d096f1d6154c320d7f36e24fd225b55ff1 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 17:43:02 +0300 Subject: [PATCH 14/25] feat/ - Added GetStarted screen. - Updated ProfileScreen to display GetStarted screen for non-logged in users. - Add GetStartedRoute to main navigation graph. --- .../main/java/com/kdbrian/sage/nav/MainNav.kt | 5 + .../main/java/com/kdbrian/sage/nav/Route.kt | 4 + .../com/kdbrian/sage/ui/screens/GetStarted.kt | 169 ++++++ .../kdbrian/sage/ui/screens/ProfileScreen.kt | 523 ++++++++++-------- 4 files changed, 458 insertions(+), 243 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/GetStarted.kt diff --git a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt index e245f98..97f7868 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import com.kdbrian.sage.ui.screens.DocumentDetails +import com.kdbrian.sage.ui.screens.GetStarted import com.kdbrian.sage.ui.screens.HomeScreen import com.kdbrian.sage.ui.screens.ProfileScreen import com.kdbrian.sage.ui.screens.TopicDetails @@ -52,6 +53,10 @@ fun MainNav() { ) } + composable{ + GetStarted() + } + } diff --git a/app/src/main/java/com/kdbrian/sage/nav/Route.kt b/app/src/main/java/com/kdbrian/sage/nav/Route.kt index 48b44b8..6b3b373 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/Route.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/Route.kt @@ -6,6 +6,10 @@ import kotlinx.serialization.Serializable sealed class Route +@Serializable +data object GetStartedRoute : Route() + + @Serializable data object HomeScreenRoute : Route() diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/GetStarted.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/GetStarted.kt new file mode 100644 index 0000000..8000d3c --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/GetStarted.kt @@ -0,0 +1,169 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kdbrian.sage.ui.theme.SageTheme + +@Composable +fun GetStarted( + modifier: Modifier = Modifier, + onGetStarted: () -> Unit = {}, + onSkip: () -> Unit = {}, +) { + + Surface( + modifier = modifier, + contentColor = Color.White + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.75f), + Color(0xFF153131), + ) + ) + ), + contentAlignment = Alignment.Center + ) { + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Spacer(Modifier.weight(1f)) + + TextButton( + modifier = Modifier.padding( + vertical = 8.dp + ), + onClick = onSkip + ) { + Text("Skip") + } + + Text( + text = buildAnnotatedString { + append("Get") + append("\n") + append("Started") + }, + style = MaterialTheme.typography.displayLarge.copy( + textAlign = TextAlign.Center + ), + modifier = Modifier + ) + + Spacer( + Modifier + .padding(24.dp) + .background(color = Color.Red) + ) + + Column { + RowAction(text = "Save documents") + RowAction(text = "Sync saved documents & favourites.") + RowAction(text = "Access a community of like minded people.") + RowAction(text = "Receive recommendations.") + + RowAction(text = "Backup all your activity.") + } + + Spacer(Modifier.padding(16.dp)) + + TextButton(onClick = onGetStarted, modifier = Modifier) { + Text(text = "Join Today") + } + + + Spacer(Modifier.weight(1f)) + + Text( + text = LoremIpsum(24).values.joinToString(), + modifier = Modifier + + .widthIn(max = 230.dp), + style = MaterialTheme.typography.bodySmall.copy( + textAlign = TextAlign.Center, + color = Color.LightGray, + fontSize = 10.sp + ) + ) + + Spacer(Modifier.weight(1f)) + + + } + + + } + } + + +} + +@Composable +private fun RowAction( + modifier: Modifier = Modifier, + icon: @Composable () -> Unit = { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = Color.Green + ) + }, + text: String = LoremIpsum(2).values.joinToString(), +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + + icon() + + Text( + text = text, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Light + ) + ) + } +} + +@Preview +@Composable +private fun GetStartedPrev() { + SageTheme { + GetStarted() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt index 971beb2..48a8922 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt @@ -1,7 +1,9 @@ package com.kdbrian.sage.ui.screens +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,6 +33,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -49,6 +55,12 @@ import com.kdbrian.sage.ui.composables.RowButton fun ProfileScreen( navHostController: NavHostController ) { + + var isLoggedIn by remember { + mutableStateOf(false) + } + + Scaffold( topBar = { Surface( @@ -69,288 +81,313 @@ fun ProfileScreen( ) } } - ) { - LazyColumn( - modifier = Modifier - .padding(it) - .padding(16.dp) - , - verticalArrangement = Arrangement.spacedBy(12.dp) + ) { paddingValues -> + Box( + modifier = Modifier.padding(paddingValues), ) { - - item { - Row( - modifier = Modifier - , - verticalAlignment = Alignment.CenterVertically, - ) { - - CircularProfileAvatar( - size = 100, - ) { } - - Column( - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalAlignment = Alignment.Start, + AnimatedContent(isLoggedIn) { + if (it) { + LazyColumn( modifier = Modifier - .weight(1f) - .padding(start = 12.dp) - .padding(6.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text(text = "Joe Doe", style = MaterialTheme.typography.displaySmall) - Text( - text = "joedoe@dev.to", - style = MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.Light - ) - ) - - - val scrollState = rememberScrollState() - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(top = 12.dp) - .horizontalScroll(scrollState) - ){ - - CategoryItem( - icon = { - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = null, - modifier = Modifier.size(25.dp) - ) - }, - color = Color(0xFFF7F0F0), - text = "Sage points", - modifier = Modifier - ) - CategoryItem( - icon = { - Icon( - imageVector = Icons.Rounded.MusicNote, - contentDescription = null, - modifier = Modifier.size(25.dp) - ) - }, - color = Color(0xFFF7F0F0), - text = "Profile Tune", - modifier = Modifier - ) - } - } - } - } + item { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + ) { - item { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text(text = "Profile", style = MaterialTheme.typography.titleLarge) - - RowButton( - leadingIcon = { - Text( - "Activity", - style = MaterialTheme.typography.titleMedium - ) - }, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - Icon(imageVector = Icons.Rounded.Schedule, contentDescription = null) - } - ) { } + CircularProfileAvatar( + size = 100, + ) { } + + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.Start, + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + .padding(6.dp) + ) { + Text( + text = "Joe Doe", + style = MaterialTheme.typography.displaySmall + ) + Text( + text = "joedoe@dev.to", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Light + ) + ) - RowButton( - leadingIcon = { - Text( - "Saved", - style = MaterialTheme.typography.titleMedium - ) - }, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - Icon(imageVector = Icons.Rounded.Bookmark, contentDescription = null) - } - ) { } - RowButton( - leadingIcon = { - Text( - "Favourite", - style = MaterialTheme.typography.titleMedium - ) - }, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - Icon(imageVector = Icons.Rounded.Favorite, contentDescription = null) + val scrollState = rememberScrollState() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .padding(top = 12.dp) + .horizontalScroll(scrollState) + ) { + + CategoryItem( + icon = { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + modifier = Modifier.size(25.dp) + ) + }, + color = Color(0xFFF7F0F0), + text = "Sage points", + modifier = Modifier + ) + + CategoryItem( + icon = { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + modifier = Modifier.size(25.dp) + ) + }, + color = Color(0xFFF7F0F0), + text = "Profile Tune", + modifier = Modifier + ) + } + } + } } - ) { } - } - } - item { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 4.dp) - ) - - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp), - shape = RoundedCornerShape(16.dp), - shadowElevation = 3.dp - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + item { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Text( - text = "Become A Sage Master", - style = MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.SemiBold - ) - ) - - Icon( - imageVector = Icons.Rounded.Verified, - contentDescription = null, - tint = Color.Blue - ) + Text(text = "Profile", style = MaterialTheme.typography.titleLarge) + + RowButton( + leadingIcon = { + Text( + "Activity", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Rounded.Schedule, + contentDescription = null + ) + } + ) { } + + RowButton( + leadingIcon = { + Text( + "Saved", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Rounded.Bookmark, + contentDescription = null + ) + } + ) { } + + RowButton( + leadingIcon = { + Text( + "Favourite", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Rounded.Favorite, + contentDescription = null + ) + } + ) { } } + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Text( - text = "Sage Master", - style = MaterialTheme.typography.labelMedium - ) - - Text( - text = "$0.30/hr", - style = MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Light - ) + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + shape = RoundedCornerShape(16.dp), + shadowElevation = 3.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Become A Sage Master", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold + ) + ) + + Icon( + imageVector = Icons.Rounded.Verified, + contentDescription = null, + tint = Color.Blue + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Sage Master", + style = MaterialTheme.typography.labelMedium + ) + + + Text( + text = "$0.30/hr", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Light + ) + ) + + + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "god Mod", + style = MaterialTheme.typography.labelMedium + ) + + + Text( + text = "$30", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Light + ) + ) + + + } + + } + } + + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + ) } + } - Row( + + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = "god Mod", - style = MaterialTheme.typography.labelMedium + modifier = Modifier.align(Alignment.Start), + text = "Preferences", + style = MaterialTheme.typography.titleLarge ) + RowButton( + leadingIcon = { + Text( + "Notification settings", + style = MaterialTheme.typography.titleMedium + ) + }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Rounded.Notifications, + contentDescription = null + ) + } + ) { } + + + TextButton(onClick = {}) { + Text( + text = "Delete Account", + style = MaterialTheme.typography.labelMedium.copy( + color = Color.Red, + ) + ) + } + + TextButton(onClick = {}) { + Text( + text = "Terms & Conditions", + style = MaterialTheme.typography.labelMedium.copy( + color = Color.Blue + ) + ) + } + Spacer(Modifier.weight(1f)) Text( - text = "$30", - style = MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Light - ) + text = "App ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.labelSmall ) - + Spacer(Modifier.weight(1f)) } - } - } - - - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 4.dp) - ) - - } - } - item { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - modifier = Modifier.align(Alignment.Start), - text = "Preferences", style = MaterialTheme.typography.titleLarge - ) - - RowButton( - leadingIcon = { - Text( - "Notification settings", - style = MaterialTheme.typography.titleMedium - ) - }, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - Icon( - imageVector = Icons.Rounded.Notifications, - contentDescription = null - ) - } - ) { } - - - TextButton(onClick = {}) { - Text( - text = "Delete Account", - style = MaterialTheme.typography.labelMedium.copy( - color = Color.Red, - ) - ) } - - TextButton(onClick = {}) { - Text( - text = "Terms & Conditions", - style = MaterialTheme.typography.labelMedium.copy( - color = Color.Blue - ) - ) - } - - Spacer(Modifier.weight(1f)) - Text( - text = "App ${BuildConfig.VERSION_NAME}", - style = MaterialTheme.typography.labelSmall + } else { + GetStarted( + onSkip = { + navHostController.popBackStack() + } ) - Spacer(Modifier.weight(1f)) - } - } - + } } } - } @Preview From 38a4a851af038613f66780ab8b8ecdb352053485 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 17:49:31 +0300 Subject: [PATCH 15/25] feat/ - define TopicRepo interface and its implementation - define TopicItem data class --- .../sage/data/remote/impl/TopicRepoImpl.kt | 14 ++++++++++++++ .../com/kdbrian/sage/domain/model/TopicItem.kt | 15 +++++++++++++++ .../com/kdbrian/sage/domain/repo/TopicRepo.kt | 8 ++++++++ 3 files changed, 37 insertions(+) create mode 100644 app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt create mode 100644 app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt create mode 100644 app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt new file mode 100644 index 0000000..be4a269 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt @@ -0,0 +1,14 @@ +package com.kdbrian.sage.data.remote.impl + +import com.kdbrian.sage.domain.model.TopicItem +import com.kdbrian.sage.domain.repo.TopicRepo + +class TopicRepoImpl : TopicRepo { + override suspend fun loadAllTopics(): Result> { + TODO("Not yet implemented") + } + + override suspend fun loadAllTopicsByQuery(query: String): Result> { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt new file mode 100644 index 0000000..8c3cfbe --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt @@ -0,0 +1,15 @@ +package com.kdbrian.sage.domain.model + +import com.kdbrian.sage.BuildConfig +import kotlinx.serialization.Serializable + +@Serializable +data class TopicItem( + val topicId: String = "", + val topicName: String = "", + val dateAdded: Long = System.currentTimeMillis(), +){ + companion object{ + const val collectionName = "${BuildConfig.APPLICATION_ID}/topics" + } +} diff --git a/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt new file mode 100644 index 0000000..02a4951 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt @@ -0,0 +1,8 @@ +package com.kdbrian.sage.domain.repo + +import com.kdbrian.sage.domain.model.TopicItem + +interface TopicRepo { + suspend fun loadAllTopics(): Result> + suspend fun loadAllTopicsByQuery(query: String): Result> +} \ No newline at end of file From 1ef7513e7bf76d81560c0845436801a6a0bac2ba Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 19:43:35 +0300 Subject: [PATCH 16/25] feat/ - add firebase analytics --- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 4 +- .../java/com/kdbrian/sage/MainActivity.kt | 14 +- app/src/main/java/com/kdbrian/sage/Sage.kt | 8 +- .../data/remote/impl/AnalyticsAdapterImpl.kt | 18 ++ .../com/kdbrian/sage/di/analyticsModule.kt | 13 ++ .../di/{templateModules.kt => coreModules.kt} | 2 +- .../com/kdbrian/sage/di/firebaseModules.kt | 14 ++ .../sage/domain/repo/AnalyticsAdapter.kt | 12 + .../main/java/com/kdbrian/sage/nav/MainNav.kt | 21 +- .../kdbrian/sage/ui/screens/CreateScreen.kt | 209 ++++++++++-------- .../com/kdbrian/sage/ui/screens/HomeScreen.kt | 2 + app/src/main/res/values/themes.xml | 8 +- build.gradle.kts | 1 + gradle/libs.versions.toml | 6 + 15 files changed, 226 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/data/remote/impl/AnalyticsAdapterImpl.kt create mode 100644 app/src/main/java/com/kdbrian/sage/di/analyticsModule.kt rename app/src/main/java/com/kdbrian/sage/di/{templateModules.kt => coreModules.kt} (65%) create mode 100644 app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt create mode 100644 app/src/main/java/com/kdbrian/sage/domain/repo/AnalyticsAdapter.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03973e3..b94e19d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,6 +17,7 @@ plugins { alias(libs.plugins.kotlin.compose) id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") kotlin("plugin.serialization") version "2.2.0" + alias(libs.plugins.google.gms.google.services) } @@ -89,6 +90,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.firebase.analytics) testImplementation(libs.junit) testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter) @@ -113,7 +115,6 @@ dependencies { //nav implementation(libs.androidx.navigation.compose) - //coil implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) @@ -123,4 +124,7 @@ dependencies { //serialization implementation(libs.kotlinx.serialization.json) + + //datastore prefs + implementation(libs.androidx.datastore.preferences.core) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a6c427c..7bb0a94 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,13 +12,13 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.Templated" + android:theme="@style/Theme.Sage" tools:targetApi="31"> + android:theme="@style/Theme.Sage"> diff --git a/app/src/main/java/com/kdbrian/sage/MainActivity.kt b/app/src/main/java/com/kdbrian/sage/MainActivity.kt index 04edd3a..6c9d476 100644 --- a/app/src/main/java/com/kdbrian/sage/MainActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -1,24 +1,28 @@ package com.kdbrian.sage +import android.app.Activity +import android.graphics.Color import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat import com.kdbrian.sage.nav.MainNav import com.kdbrian.sage.ui.theme.SageTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - enableEdgeToEdge() - } else { - WindowCompat.setDecorFitsSystemWindows(window, false) - } setContent { App { diff --git a/app/src/main/java/com/kdbrian/sage/Sage.kt b/app/src/main/java/com/kdbrian/sage/Sage.kt index 22e1836..2dbd1c6 100644 --- a/app/src/main/java/com/kdbrian/sage/Sage.kt +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -1,7 +1,9 @@ package com.kdbrian.sage import android.app.Application -import com.kdbrian.sage.di.templateModules +import com.kdbrian.sage.di.analyticsModule +import com.kdbrian.sage.di.coreModules +import com.kdbrian.sage.di.firebaseModules import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin @@ -20,7 +22,9 @@ class Sage : Application() { androidContext(this@Sage) modules( - templateModules, + analyticsModule, + coreModules, + firebaseModules ) } } diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/AnalyticsAdapterImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/AnalyticsAdapterImpl.kt new file mode 100644 index 0000000..8fddbe9 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/AnalyticsAdapterImpl.kt @@ -0,0 +1,18 @@ +package com.kdbrian.sage.data.remote.impl + +import androidx.core.os.bundleOf +import androidx.core.os.toPersistableBundle +import com.google.firebase.analytics.FirebaseAnalytics +import com.kdbrian.sage.domain.repo.AnalyticsAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AnalyticsAdapterImpl( + private val analytics: FirebaseAnalytics +) : AnalyticsAdapter { + override suspend fun logEvent(event: String, extras: Map) = + withContext(Dispatchers.Default) { + val bundle = bundleOf(*extras.toList().toTypedArray()) + analytics.logEvent(event, bundle) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/analyticsModule.kt b/app/src/main/java/com/kdbrian/sage/di/analyticsModule.kt new file mode 100644 index 0000000..28b9841 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/analyticsModule.kt @@ -0,0 +1,13 @@ +package com.kdbrian.sage.di + +import com.kdbrian.sage.data.remote.impl.AnalyticsAdapterImpl +import com.kdbrian.sage.domain.repo.AnalyticsAdapter +import org.koin.dsl.module + +val analyticsModule = module { + single { + AnalyticsAdapterImpl( + analytics = get() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/templateModules.kt b/app/src/main/java/com/kdbrian/sage/di/coreModules.kt similarity index 65% rename from app/src/main/java/com/kdbrian/sage/di/templateModules.kt rename to app/src/main/java/com/kdbrian/sage/di/coreModules.kt index 9f09545..88ee1aa 100644 --- a/app/src/main/java/com/kdbrian/sage/di/templateModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/coreModules.kt @@ -2,6 +2,6 @@ package com.kdbrian.sage.di import org.koin.dsl.module -val templateModules = module { +val coreModules = module { } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt b/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt new file mode 100644 index 0000000..805a392 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt @@ -0,0 +1,14 @@ +package com.kdbrian.sage.di + +import android.content.Context +import com.google.firebase.analytics.FirebaseAnalytics +import org.koin.dsl.module + +val firebaseModules = module { + + single { + FirebaseAnalytics.getInstance(get()) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/repo/AnalyticsAdapter.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/AnalyticsAdapter.kt new file mode 100644 index 0000000..46be65f --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/AnalyticsAdapter.kt @@ -0,0 +1,12 @@ +package com.kdbrian.sage.domain.repo + + +//data class AnalyticEvent( +// +//) + +interface AnalyticsAdapter { + + suspend fun logEvent(event: String, extras : Map = emptyMap()) + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt index 97f7868..c1bdb4a 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -1,6 +1,14 @@ package com.kdbrian.sage.nav +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -15,7 +23,14 @@ import com.kdbrian.sage.ui.screens.TopicDetails fun MainNav() { val navController = rememberNavController() - NavHost(navController = navController, startDestination = HomeScreenRoute) { + + NavHost( + modifier = Modifier + .fillMaxSize() + , + navController = navController, + startDestination = HomeScreenRoute + ) { composable { HomeScreen( @@ -34,7 +49,7 @@ fun MainNav() { composable { TopicDetails( navController = navController, - onClose = {navController.popBackStack()} + onClose = { navController.popBackStack() } ) } @@ -53,7 +68,7 @@ fun MainNav() { ) } - composable{ + composable { GetStarted() } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt index a8d2e0f..5182205 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -4,7 +4,9 @@ import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,11 +18,13 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -111,134 +115,145 @@ fun CreateScreen() { androidx.compose.material3.SnackbarHost(hostState = snackbarHostState) }, modifier = Modifier.fillMaxSize() - ) { padding -> + ) { pd -> val scrollState = rememberScrollState() - Column( + + Box( modifier = Modifier - .padding(16.dp) .fillMaxSize() - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Column( modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) + .padding(16.dp) + .fillMaxSize() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text(text = "Create", style = MaterialTheme.typography.headlineLarge) - Text( - text = LoremIpsum(12).values.joinToString(), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Light, - textAlign = TextAlign.Center + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + + Text(text = "Create", style = MaterialTheme.typography.headlineLarge) + Text( + text = LoremIpsum(12).values.joinToString(), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) ) - ) - } + } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(24.dp)) - RowButton( - modifier = Modifier - .fillMaxWidth(), - leadingIcon = { - Row { - Icon( - imageVector = iconAndText.second, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp) - ) - Text( - text = iconAndText.first ?: "Add Document", - style = MaterialTheme.typography.titleMedium + RowButton( + modifier = Modifier + .fillMaxWidth(), + leadingIcon = { + Row { + Icon( + imageVector = iconAndText.second, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = iconAndText.first ?: "Add Document", + style = MaterialTheme.typography.titleMedium + ) + } + }, + onClick = { + pickDocument.launch( + arrayOf( + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) ) } - }, - onClick = { - pickDocument.launch( - arrayOf( - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) - ) - } - ) + ) - RoundedInputField( - modifier = Modifier - .fillMaxWidth(), - placeholder = "rename document" - ) + RoundedInputField( + modifier = Modifier + .fillMaxWidth(), + placeholder = "rename document" + ) - RoundedInputField( - singleLine = false, - modifier = Modifier - .fillMaxWidth(), - placeholder = "Summary" - ) + RoundedInputField( + singleLine = false, + modifier = Modifier + .fillMaxWidth(), + placeholder = "Summary" + ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalAlignment = Alignment.Start - ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .padding(top = 12.dp), + horizontalAlignment = Alignment.Start ) { - Text(text = "Topics", style = MaterialTheme.typography.titleLarge) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = "Topics", style = MaterialTheme.typography.titleLarge) + + + CategoryItem(text = "Add", onClick = { + if (docUri == Uri.EMPTY || docUri == null) { + coroutineScope.launch { + snackbarHostState.showSnackbar("Please select a document") + } + } else + isAddingTopic = !isAddingTopic + }) + } - CategoryItem(text = "Add", onClick = { - if (docUri == Uri.EMPTY || docUri == null) { - coroutineScope.launch { - snackbarHostState.showSnackbar("Please select a document") - } - } else - isAddingTopic = !isAddingTopic - }) } + LazyHorizontalGrid( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(4.dp), + rows = GridCells.Fixed(2), + ) { + itemsIndexed(topics) { index, topic -> + key(index) { + CategoryItem( + text = topic, + modifier = Modifier.padding(4.dp) + ) + } + } + } } - LazyHorizontalGrid( + AnimatedVisibility( modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .padding(4.dp), - rows = GridCells.Fixed(3), + .align(Alignment.BottomCenter) + .padding(16.dp), + visible = docUri != Uri.EMPTY && docUri != null ) { - itemsIndexed(topics) { index, topic -> - key(index) { - CategoryItem( - text = topic, - modifier = Modifier.padding(4.dp) - ) - } - } - } - - RowButton( - modifier = Modifier.align(Alignment.CenterHorizontally), - leadingIcon = { - Text( - text = "Save", - style = MaterialTheme.typography.titleMedium - ) + Button( + onClick = {}, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(8.dp), + shape = RoundedCornerShape(24.dp) + ) { + Text(text = "Upload") } - ) { - } - } if (isAddingTopic) { diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt index 91480de..7a95e91 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Icon diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b9347bb..273bf3a 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,11 @@ - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 1a19b15..a13fed3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.google.gms.google.services) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3d5b7f..f2a5443 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ agp = "8.10.1" coilCompose = "3.3.0" coilNetworkOkhttp = "3.3.0" +datastorePreferencesCore = "1.1.7" koinBom = "4.1.0" kotlin = "2.2.0" coreKtx = "1.16.0" @@ -16,10 +17,13 @@ junitJupiter = "5.8.1" materialIconsExtended = "1.7.8" navigationCompose = "2.9.3" timber = "5.0.1" +googleGmsGoogleServices = "4.4.3" +firebaseAnalytics = "23.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferencesCore" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } @@ -45,9 +49,11 @@ koin-core = { module = "io.insert-koin:koin-core" } koin-android = { module = "io.insert-koin:koin-android" } koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebaseAnalytics" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "googleGmsGoogleServices" } From ffd4242b2802917bb6e694feaf21c3d9b0c19ca3 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 20:17:44 +0300 Subject: [PATCH 17/25] feat/ - create basic notification utils. - Add NotificationChannel for SDK TIRAMISU - Add NotificationData data class - add RoundedInputField Shape param - Add POST_NOTIFICATIONS permission - HomeScreenUiState impl - CreateScreenUiState impl - update HomeScreen to use new state - add RoundedInputField.shape - Add firestore dependency - update CreateScreen --- app/build.gradle.kts | 6 +++++ app/src/main/AndroidManifest.xml | 2 ++ .../java/com/kdbrian/sage/MainActivity.kt | 18 +++++++++++++ .../sage/data/remote/impl/TopicRepoImpl.kt | 4 ++- .../sage/ui/composables/RoundedInputField.kt | 4 ++- .../kdbrian/sage/ui/screens/CreateScreen.kt | 3 ++- .../com/kdbrian/sage/ui/screens/HomeScreen.kt | 14 +--------- .../kdbrian/sage/ui/screens/LandingPage.kt | 3 ++- .../sage/ui/state/CreateScreenUiState.kt | 8 ++++++ .../sage/ui/state/CreateScreenViewModel.kt | 6 +++++ .../sage/ui/state/HomeScreenUiState.kt | 8 ++++++ .../sage/ui/state/HomeScreenViewModel.kt | 20 ++++++++++++++ .../com/kdbrian/sage/util/NotificationData.kt | 13 +++++++++ .../kdbrian/sage/util/NotificationUtils.kt | 27 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 4 +++ 16 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt create mode 100644 app/src/main/java/com/kdbrian/sage/util/NotificationData.kt create mode 100644 app/src/main/java/com/kdbrian/sage/util/NotificationUtils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b94e19d..6b9f5cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.firebase.analytics) + implementation(libs.firebase.firestore) testImplementation(libs.junit) testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter) @@ -127,4 +128,9 @@ dependencies { //datastore prefs implementation(libs.androidx.datastore.preferences.core) + + //accompanist permissions + implementation(libs.accompanist.permissions) + + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7bb0a94..5aad687 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + = Build.VERSION_CODES.TIRAMISU) { + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel( + NotificationChannel( + applicationContext.getString(R.string.default_notification_channel), + "Sage Notifications", + NotificationManager.IMPORTANCE_HIGH + ) + ) + } + + setContent { App { diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt index be4a269..e626446 100644 --- a/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt @@ -3,7 +3,9 @@ package com.kdbrian.sage.data.remote.impl import com.kdbrian.sage.domain.model.TopicItem import com.kdbrian.sage.domain.repo.TopicRepo -class TopicRepoImpl : TopicRepo { +class TopicRepoImpl( +// private val firest +): TopicRepo { override suspend fun loadAllTopics(): Result> { TODO("Not yet implemented") } diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt index 9e0f3f0..c256c38 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kdbrian.sage.ui.theme.SageTheme @@ -25,6 +26,7 @@ fun RoundedInputField( fieldState: TextFieldState = rememberTextFieldState(), onValueChange: ((String) -> Unit)? = null, placeholder: String = "Placeholder", + shape : Shape = RoundedCornerShape(24.dp), leadingIcon: (@Composable () -> Unit)? = null, keyBoardActions: KeyboardActions = KeyboardActions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, @@ -54,7 +56,7 @@ fun RoundedInputField( modifier = modifier, singleLine = singleLine, minLines = if (singleLine) 1 else 3, - shape = RoundedCornerShape(12.dp), + shape = shape, placeholder = { Text(text = placeholder, style = MaterialTheme.typography.labelMedium) }, diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt index 5182205..534e6c1 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -58,6 +58,7 @@ import com.kdbrian.sage.ui.theme.SageTheme import com.kdbrian.sage.util.FileUtils.getFileName import kotlinx.coroutines.launch + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateScreen() { @@ -86,6 +87,7 @@ fun CreateScreen() { } val snackbarHostState = remember { SnackbarHostState() } + val pickDocument = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { @@ -97,7 +99,6 @@ fun CreateScreen() { docUri = it } } - val iconAndText by remember { derivedStateOf { if (docUri != Uri.EMPTY && docUri != null) { diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt index 7a95e91..bed4bed 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -1,25 +1,14 @@ package com.kdbrian.sage.ui.screens -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -27,11 +16,10 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.kdbrian.sage.nav.CreateScreenRoute import com.kdbrian.sage.nav.HomeScreenRoute -import com.kdbrian.sage.ui.composables.CircularProfileAvatar -import com.kdbrian.sage.ui.composables.RoundedInputField import com.kdbrian.sage.ui.theme.SageTheme import com.kdbrian.sage.ui.util.BottomBarItem + @Composable fun HomeScreen( navHostController: NavHostController = rememberNavController(), diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt index 4c609ca..0eb6bc0 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt @@ -78,7 +78,8 @@ fun LandingPage( imageVector = Icons.Rounded.Search, contentDescription = null ) - } + }, + ) } diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt new file mode 100644 index 0000000..7cf0b91 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt @@ -0,0 +1,8 @@ +package com.kdbrian.sage.ui.state + +import android.net.Uri + +data class CreateScreenUiState( + val docUri: Uri? = null, + val topics: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt new file mode 100644 index 0000000..3c2e36b --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt @@ -0,0 +1,6 @@ +package com.kdbrian.sage.ui.state + +import androidx.lifecycle.ViewModel + +class CreateScreenViewModel: ViewModel() { +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt new file mode 100644 index 0000000..62e1de9 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt @@ -0,0 +1,8 @@ +package com.kdbrian.sage.ui.state + +import androidx.compose.foundation.text.input.TextFieldState + +data class HomeScreenUiState( + val searchQuery: TextFieldState = TextFieldState(), + val topics: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt new file mode 100644 index 0000000..b38d75b --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt @@ -0,0 +1,20 @@ +package com.kdbrian.sage.ui.state + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class HomeScreenViewModel : ViewModel() { + + + private val _mutableState: MutableStateFlow = + MutableStateFlow(HomeScreenUiState()) + private val uiState = _mutableState.asStateFlow() + + fun updateQuery(query: String) { + _mutableState.value.searchQuery.setTextAndPlaceCursorAtEnd(query) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/util/NotificationData.kt b/app/src/main/java/com/kdbrian/sage/util/NotificationData.kt new file mode 100644 index 0000000..81ca73c --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/NotificationData.kt @@ -0,0 +1,13 @@ +package com.kdbrian.sage.util + +import androidx.core.app.NotificationCompat + +data class NotificationData( + val channelId: String, + val title: String, + val message: String, + val smallIcon: Int, + val bigText: String? = null, + val priority: Int = NotificationCompat.PRIORITY_DEFAULT, + val autoCancel: Boolean = true +) diff --git a/app/src/main/java/com/kdbrian/sage/util/NotificationUtils.kt b/app/src/main/java/com/kdbrian/sage/util/NotificationUtils.kt new file mode 100644 index 0000000..85f9ca8 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/NotificationUtils.kt @@ -0,0 +1,27 @@ +package com.kdbrian.sage.util + +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat + +object NotificationUtils { + + + @androidx.annotation.RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + fun Context.showNotification(data: NotificationData, notificationId: Int) { + val builder = NotificationCompat.Builder(this, data.channelId).setSmallIcon(data.smallIcon) + .setContentTitle(data.title).setContentText(data.message).setPriority(data.priority) + .setAutoCancel(data.autoCancel) + + data.bigText?.let { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) + } + + with(NotificationManagerCompat.from(this)) { + notify(notificationId, builder.build()) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6b0be0..cf98e96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Sage + App Notifications \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2a5443..a7f23e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +accompanistPermissions = "0.37.3" agp = "8.10.1" coilCompose = "3.3.0" coilNetworkOkhttp = "3.3.0" @@ -19,9 +20,11 @@ navigationCompose = "2.9.3" timber = "5.0.1" googleGmsGoogleServices = "4.4.3" firebaseAnalytics = "23.0.0" +firebaseFirestore = "26.0.0" [libraries] +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferencesCore" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } @@ -50,6 +53,7 @@ koin-android = { module = "io.insert-koin:koin-android" } koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebaseAnalytics" } +firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore", version.ref = "firebaseFirestore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 9a757dcafdd0e892f462a41962be91163447a1e8 Mon Sep 17 00:00:00 2001 From: kidiga Date: Fri, 8 Aug 2025 21:53:07 +0300 Subject: [PATCH 18/25] feat/ - add default topics to firestore on initial run - implement AppDataStore - make TopicRepo return nullable TopicItem - add brightness_alert icon for notification - update firebaseModules with FirebaseFirestore - add topicModules --- app/build.gradle.kts | 2 +- .../java/com/kdbrian/sage/MainActivity.kt | 107 ++++++++++++++++++ app/src/main/java/com/kdbrian/sage/Sage.kt | 4 +- .../sage/data/remote/impl/TopicRepoImpl.kt | 63 +++++++++-- .../java/com/kdbrian/sage/di/coreModules.kt | 7 ++ .../com/kdbrian/sage/di/firebaseModules.kt | 5 + .../java/com/kdbrian/sage/di/topicModules.kt | 16 +++ .../kdbrian/sage/domain/model/TopicItem.kt | 2 +- .../com/kdbrian/sage/domain/repo/TopicRepo.kt | 4 +- .../com/kdbrian/sage/util/AppDataStore.kt | 33 ++++++ .../drawable/outline_brightness_alert_24.xml | 5 + gradle/libs.versions.toml | 2 + 12 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/di/topicModules.kt create mode 100644 app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt create mode 100644 app/src/main/res/drawable/outline_brightness_alert_24.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b9f5cb..3076204 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,7 +127,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) //datastore prefs - implementation(libs.androidx.datastore.preferences.core) + implementation(libs.androidx.datastore.preferences) //accompanist permissions implementation(libs.accompanist.permissions) diff --git a/app/src/main/java/com/kdbrian/sage/MainActivity.kt b/app/src/main/java/com/kdbrian/sage/MainActivity.kt index deec149..5e2b26b 100644 --- a/app/src/main/java/com/kdbrian/sage/MainActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -1,5 +1,6 @@ package com.kdbrian.sage +import android.Manifest import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager @@ -11,18 +12,38 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.Firebase +import com.google.firebase.firestore.FirebaseFirestore +import com.kdbrian.sage.domain.model.TopicItem import com.kdbrian.sage.nav.MainNav import com.kdbrian.sage.ui.theme.SageTheme +import com.kdbrian.sage.util.AppDataStore +import com.kdbrian.sage.util.NotificationData +import com.kdbrian.sage.util.NotificationUtils.showNotification +import kotlinx.coroutines.flow.first +import org.koin.compose.koinInject +import timber.log.Timber +import kotlin.jvm.Throws +import kotlin.random.Random class MainActivity : ComponentActivity() { + + @OptIn(ExperimentalPermissionsApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -44,10 +65,96 @@ class MainActivity : ComponentActivity() { setContent { App { + + val firebaseFirestore = koinInject() + val appDataStore = koinInject() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionState = + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + LaunchedEffect(Unit) { + if (!permissionState.status.isGranted) { + permissionState.launchPermissionRequest() + } + } + } + + LaunchedEffect(Unit) { + if (appDataStore.firstTime.first()){ //if is first time + uploadDefaultTopics(firebaseFirestore) { + + val data = NotificationData( + channelId = applicationContext.getString(R.string.default_notification_channel), + title = if (it != null) "Error" else "Success", + message = it?.message ?: "Uploaded to firestore done.", + smallIcon = R.drawable.outline_brightness_alert_24 + ) + + applicationContext.showNotification( + data = data, + notificationId = Random.nextInt(1, 9) + ) + + + } + appDataStore.updateFirstTime(false); + } + } + + MainNav() } } + + } + + fun uploadDefaultTopics(db: FirebaseFirestore, onCompleteListener: (Throwable?) -> Unit = {}) { + val topics = listOf( + "Business", + "Finance", + "Lifestyle", + "Technology", + "Health", + "Travel", + "Food", + "Education", + "Sports", + "Science", + "Entertainment", + "Politics", + "Environment", + "Art", + "Music", + "History", + "Fashion", + "Relationships", + "Self Improvement", + "Real Estate" + ).map { topicName -> + TopicItem(topicName = topicName) + } + + val batch = db.batch() + val topicsRef = db.collection(TopicItem.collectionName) + + topics.forEach { topic -> + val docRef = topicsRef.document() + val topicWithId = topic.copy(topicId = docRef.id) // set topicId to Firestore doc id + batch.set(docRef, topicWithId) + } + + batch.commit() + .addOnSuccessListener { + Timber.d("Firestore All topics uploaded successfully") + onCompleteListener(null) + } + .addOnFailureListener { e -> + Timber.e("Firestore Error uploading topics ${e.localizedMessage}") + onCompleteListener(e) + } } + + } diff --git a/app/src/main/java/com/kdbrian/sage/Sage.kt b/app/src/main/java/com/kdbrian/sage/Sage.kt index 2dbd1c6..6035a11 100644 --- a/app/src/main/java/com/kdbrian/sage/Sage.kt +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -4,6 +4,7 @@ import android.app.Application import com.kdbrian.sage.di.analyticsModule import com.kdbrian.sage.di.coreModules import com.kdbrian.sage.di.firebaseModules +import com.kdbrian.sage.di.topicModules import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin @@ -24,7 +25,8 @@ class Sage : Application() { modules( analyticsModule, coreModules, - firebaseModules + firebaseModules, + topicModules, ) } } diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt index e626446..8331a8b 100644 --- a/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt @@ -1,16 +1,61 @@ package com.kdbrian.sage.data.remote.impl +import com.google.firebase.firestore.Filter +import com.google.firebase.firestore.FirebaseFirestore import com.kdbrian.sage.domain.model.TopicItem import com.kdbrian.sage.domain.repo.TopicRepo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext class TopicRepoImpl( -// private val firest -): TopicRepo { - override suspend fun loadAllTopics(): Result> { - TODO("Not yet implemented") - } - - override suspend fun loadAllTopicsByQuery(query: String): Result> { - TODO("Not yet implemented") - } + private val firestore: FirebaseFirestore +) : TopicRepo { + + + override suspend fun loadAllTopics(): Result> = + withContext(Dispatchers.Default) { + try { + val documentSnapshots = firestore.collection(TopicItem.collectionName).get().await() + if (!documentSnapshots.isEmpty) { + val topicItems = documentSnapshots.documents + .map { it.toObject(TopicItem::class.java) } + .toList() + + Result.success( + topicItems + ) + } else { + Result.failure(Exception("No topics found")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun loadAllTopicsByQuery(query: String): Result> = + withContext(Dispatchers.Default) { + try { + val documentSnapshots = firestore + .collection(TopicItem.collectionName) + .whereEqualTo("topicName", query) + .get() + .await() + + if (!documentSnapshots.isEmpty) { + val topicItems = documentSnapshots.documents + .map { it.toObject(TopicItem::class.java) } + .toList() + + Result.success( + topicItems + ) + } else { + Result.failure(Exception("No topics found")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/coreModules.kt b/app/src/main/java/com/kdbrian/sage/di/coreModules.kt index 88ee1aa..d4e3c1c 100644 --- a/app/src/main/java/com/kdbrian/sage/di/coreModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/coreModules.kt @@ -1,7 +1,14 @@ package com.kdbrian.sage.di +import com.kdbrian.sage.util.AppDataStore import org.koin.dsl.module val coreModules = module { + single { + AppDataStore( + context = get() + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt b/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt index 805a392..8718d0a 100644 --- a/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt @@ -2,6 +2,7 @@ package com.kdbrian.sage.di import android.content.Context import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.firestore.FirebaseFirestore import org.koin.dsl.module val firebaseModules = module { @@ -11,4 +12,8 @@ val firebaseModules = module { } + single { + FirebaseFirestore.getInstance() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/topicModules.kt b/app/src/main/java/com/kdbrian/sage/di/topicModules.kt new file mode 100644 index 0000000..616a7fb --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/topicModules.kt @@ -0,0 +1,16 @@ +package com.kdbrian.sage.di + +import com.kdbrian.sage.data.remote.impl.TopicRepoImpl +import com.kdbrian.sage.domain.repo.TopicRepo +import org.koin.dsl.module + +val topicModules = module { + + single { + TopicRepoImpl( + firestore = get() + ) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt index 8c3cfbe..7eb53c8 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt @@ -10,6 +10,6 @@ data class TopicItem( val dateAdded: Long = System.currentTimeMillis(), ){ companion object{ - const val collectionName = "${BuildConfig.APPLICATION_ID}/topics" + const val collectionName = "${BuildConfig.APPLICATION_ID}/topics/default" } } diff --git a/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt index 02a4951..168901e 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt @@ -3,6 +3,6 @@ package com.kdbrian.sage.domain.repo import com.kdbrian.sage.domain.model.TopicItem interface TopicRepo { - suspend fun loadAllTopics(): Result> - suspend fun loadAllTopicsByQuery(query: String): Result> + suspend fun loadAllTopics(): Result> + suspend fun loadAllTopicsByQuery(query: String): Result> } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt new file mode 100644 index 0000000..c375642 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt @@ -0,0 +1,33 @@ +package com.kdbrian.sage.util + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.map + + +val Context.appDataStore by preferencesDataStore(name = "app_data") + + +class AppDataStore( + private val context: Context +) { + + private val _isFirstTime = booleanPreferencesKey(AppDatastoreKeys.firstTime) + suspend fun updateFirstTime(firstTime: Boolean) { + context.appDataStore.edit { preferences -> + preferences[_isFirstTime] = firstTime + } + } + + val firstTime = context.appDataStore.data.map { preferences -> + preferences[_isFirstTime] ?: true + } + + + object AppDatastoreKeys { + const val firstTime = "isFirstTime" + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_brightness_alert_24.xml b/app/src/main/res/drawable/outline_brightness_alert_24.xml new file mode 100644 index 0000000..e742a1b --- /dev/null +++ b/app/src/main/res/drawable/outline_brightness_alert_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a7f23e1..cec9ec1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,8 @@ firebaseFirestore = "26.0.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-core-android = { module = "androidx.datastore:datastore-core-android", version.ref = "datastorePreferencesCore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferencesCore" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferencesCore" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } From ce4f4946a1b418875473bf5d915bafb1afc1245d Mon Sep 17 00:00:00 2001 From: kidiga Date: Sat, 9 Aug 2025 13:01:46 +0300 Subject: [PATCH 19/25] feat/ - create basic document model - create doc repo - update models collection paths - finish off topic details ui and view model - inject viewmodels into screens - update appDataStore - make topic card surface roundness smaller --- .../java/com/kdbrian/sage/MainActivity.kt | 48 +- app/src/main/java/com/kdbrian/sage/Sage.kt | 4 + .../sage/data/remote/impl/DocumentRepoImpl.kt | 128 ++++++ .../sage/data/remote/impl/TopicRepoImpl.kt | 31 +- .../com/kdbrian/sage/di/documentModules.kt | 15 + .../java/com/kdbrian/sage/di/homeModules.kt | 10 + .../java/com/kdbrian/sage/di/topicModules.kt | 4 + .../sage/domain/model/DocumentModel.kt | 29 ++ .../kdbrian/sage/domain/model/TopicItem.kt | 3 +- .../kdbrian/sage/domain/repo/DocumentRepo.kt | 20 + .../com/kdbrian/sage/domain/repo/TopicRepo.kt | 6 +- .../main/java/com/kdbrian/sage/nav/MainNav.kt | 19 +- .../main/java/com/kdbrian/sage/nav/Route.kt | 2 +- .../sage/ui/composables/RoundedInputField.kt | 2 +- .../kdbrian/sage/ui/composables/TopicCard.kt | 52 ++- .../com/kdbrian/sage/ui/screens/HomeScreen.kt | 17 +- .../kdbrian/sage/ui/screens/LandingPage.kt | 428 ++++++++++++------ .../kdbrian/sage/ui/screens/TopicDetails.kt | 52 ++- .../sage/ui/state/HomeScreenUiState.kt | 5 +- .../sage/ui/state/HomeScreenViewModel.kt | 42 +- .../sage/ui/state/TopicDetailsViewModel.kt | 94 ++++ .../com/kdbrian/sage/util/AppDataStore.kt | 23 +- 22 files changed, 814 insertions(+), 220 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt create mode 100644 app/src/main/java/com/kdbrian/sage/di/documentModules.kt create mode 100644 app/src/main/java/com/kdbrian/sage/di/homeModules.kt create mode 100644 app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt create mode 100644 app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt diff --git a/app/src/main/java/com/kdbrian/sage/MainActivity.kt b/app/src/main/java/com/kdbrian/sage/MainActivity.kt index 5e2b26b..73249ce 100644 --- a/app/src/main/java/com/kdbrian/sage/MainActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -1,45 +1,24 @@ package com.kdbrian.sage import android.Manifest -import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager -import android.graphics.Color import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsControllerCompat import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.google.android.gms.tasks.OnCompleteListener -import com.google.firebase.Firebase import com.google.firebase.firestore.FirebaseFirestore import com.kdbrian.sage.domain.model.TopicItem import com.kdbrian.sage.nav.MainNav import com.kdbrian.sage.ui.theme.SageTheme -import com.kdbrian.sage.util.AppDataStore -import com.kdbrian.sage.util.NotificationData -import com.kdbrian.sage.util.NotificationUtils.showNotification -import kotlinx.coroutines.flow.first -import org.koin.compose.koinInject import timber.log.Timber -import kotlin.jvm.Throws -import kotlin.random.Random class MainActivity : ComponentActivity() { @@ -66,9 +45,10 @@ class MainActivity : ComponentActivity() { setContent { App { - val firebaseFirestore = koinInject() - val appDataStore = koinInject() - + /* val firebaseFirestore = koinInject() + val appDataStore = koinInject() + val coroutineScope = rememberCoroutineScope() + */ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val permissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) @@ -79,7 +59,7 @@ class MainActivity : ComponentActivity() { } } - LaunchedEffect(Unit) { + /*LaunchedEffect(Unit) { if (appDataStore.firstTime.first()){ //if is first time uploadDefaultTopics(firebaseFirestore) { @@ -95,11 +75,13 @@ class MainActivity : ComponentActivity() { notificationId = Random.nextInt(1, 9) ) + coroutineScope.launch { + appDataStore.updateFirstTime(false); + } } - appDataStore.updateFirstTime(false); } - } + }*/ MainNav() @@ -108,7 +90,9 @@ class MainActivity : ComponentActivity() { } - fun uploadDefaultTopics(db: FirebaseFirestore, onCompleteListener: (Throwable?) -> Unit = {}) { + private fun uploadDefaultTopics( + db: FirebaseFirestore, onCompleteListener: (Throwable?) -> Unit = {} + ) { val topics = listOf( "Business", "Finance", @@ -135,7 +119,7 @@ class MainActivity : ComponentActivity() { } val batch = db.batch() - val topicsRef = db.collection(TopicItem.collectionName) + val topicsRef = db.collection(TopicItem.defaultCollectionName) topics.forEach { topic -> val docRef = topicsRef.document() @@ -143,12 +127,10 @@ class MainActivity : ComponentActivity() { batch.set(docRef, topicWithId) } - batch.commit() - .addOnSuccessListener { + batch.commit().addOnSuccessListener { Timber.d("Firestore All topics uploaded successfully") onCompleteListener(null) - } - .addOnFailureListener { e -> + }.addOnFailureListener { e -> Timber.e("Firestore Error uploading topics ${e.localizedMessage}") onCompleteListener(e) } diff --git a/app/src/main/java/com/kdbrian/sage/Sage.kt b/app/src/main/java/com/kdbrian/sage/Sage.kt index 6035a11..15f3b45 100644 --- a/app/src/main/java/com/kdbrian/sage/Sage.kt +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -3,7 +3,9 @@ package com.kdbrian.sage import android.app.Application import com.kdbrian.sage.di.analyticsModule import com.kdbrian.sage.di.coreModules +import com.kdbrian.sage.di.documentModules import com.kdbrian.sage.di.firebaseModules +import com.kdbrian.sage.di.homeModules import com.kdbrian.sage.di.topicModules import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger @@ -25,7 +27,9 @@ class Sage : Application() { modules( analyticsModule, coreModules, + documentModules, firebaseModules, + homeModules, topicModules, ) } diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt new file mode 100644 index 0000000..60ee8e0 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt @@ -0,0 +1,128 @@ +package com.kdbrian.sage.data.remote.impl + +import com.google.firebase.firestore.FirebaseFirestore +import com.kdbrian.sage.domain.model.DocumentModel +import com.kdbrian.sage.domain.repo.DocumentRepo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext + +class DocumentRepoImpl( + private val firebaseFirestore: FirebaseFirestore +) : DocumentRepo { + override suspend fun loadDefaultDocumentsAll(): Result> = withContext( + Dispatchers.Default + ) { + try { + val documentSnapshots = + firebaseFirestore.collection(DocumentModel.defaultCollectionName) + .get() + .await() + + val documentModels = documentSnapshots.documents + .map { it.toObject(DocumentModel::class.java) } + .toList() + Result.success(documentModels) + } catch (e: Exception) { + Result.failure(e) + + } + + } + + override suspend fun loadUploadedDocumentsAll(): Result> = withContext( + Dispatchers.Default + ) { + try { + val documentSnapshots = + firebaseFirestore.collection(DocumentModel.generatedCollectionName) + .get() + .await() + + val documentModels = documentSnapshots.documents + .map { it.toObject(DocumentModel::class.java) } + .toList() + + Result.success(documentModels) + } catch (e: Exception) { + Result.failure(e) + + } + + } + + override suspend fun loadDefaultDocumentsByTopic(topicId: String): Result> = + withContext(Dispatchers.Default) { + try { + val documentSnapshots = + firebaseFirestore.collection(DocumentModel.defaultCollectionName) + .whereArrayContains("topics", topicId) + .get() + .await() + val documentModels = documentSnapshots.documents + .map { it.toObject(DocumentModel::class.java) } + .toList() + + Result.success(documentModels) + } catch (e: Exception) { + Result.failure(e) + + } + } + + override suspend fun loadUploadedDocumentsByTopic(topicId: String): Result> = + withContext(Dispatchers.Default) { + + try { + val documentSnapshots = + firebaseFirestore.collection(DocumentModel.generatedCollectionName) + .whereArrayContains("topics", topicId) + .get() + .await() + val documentModels = documentSnapshots.documents + .map { it.toObject(DocumentModel::class.java) } + .toList() + + Result.success(documentModels) + } catch (e: Exception) { + Result.failure(e) + + } + } + + override suspend fun loadDefaultDocumentsByQuery(query: String): Result> = + withContext(Dispatchers.Default) { + loadDefaultDocumentsAll() + .fold( + onSuccess = { documents -> + val documentModels = documents.filter { + (it?.documentName?.contains(query, ignoreCase = true) == true) || + (it?.summary?.contains(query, ignoreCase = true) == true) + } + + Result.success(documentModels) + }, + onFailure = { + Result.failure(it) + } + + ) + } + + override suspend fun loadUploadedDocumentsByQuery(query: String): Result> = + withContext(Dispatchers.Default) { + loadUploadedDocumentsAll() + .fold( + onSuccess = { documents -> + val documentModels = documents.filter { + (it?.documentName?.contains(query, ignoreCase = true) == true) || + (it?.summary?.contains(query, ignoreCase = true) == true) + } + Result.success(documentModels) + }, + onFailure = { + Result.failure(it) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt index 8331a8b..aa7ffe1 100644 --- a/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt @@ -1,22 +1,43 @@ package com.kdbrian.sage.data.remote.impl -import com.google.firebase.firestore.Filter import com.google.firebase.firestore.FirebaseFirestore import com.kdbrian.sage.domain.model.TopicItem import com.kdbrian.sage.domain.repo.TopicRepo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import timber.log.Timber class TopicRepoImpl( private val firestore: FirebaseFirestore ) : TopicRepo { + override suspend fun loadTopicInfoById(topicId: String): Result = + withContext(Dispatchers.Default) { + try { + val documentSnapshots = + firestore.collection(TopicItem.defaultCollectionName).document(topicId).get() + .await() + val topicDocument = documentSnapshots.toObject(TopicItem::class.java) + if (topicDocument != null) { + + Timber.tag("topicDocument").d(Json.encodeToString(topicDocument)) + + Result.success(topicDocument) + } else { + Result.failure(Exception("No topics found")) + } + } catch (e: Exception) { + Result.failure(e) + } + } - override suspend fun loadAllTopics(): Result> = + override suspend fun loadDefaultTopics(): Result> = withContext(Dispatchers.Default) { try { - val documentSnapshots = firestore.collection(TopicItem.collectionName).get().await() + val documentSnapshots = + firestore.collection(TopicItem.defaultCollectionName).get().await() if (!documentSnapshots.isEmpty) { val topicItems = documentSnapshots.documents .map { it.toObject(TopicItem::class.java) } @@ -33,11 +54,11 @@ class TopicRepoImpl( } } - override suspend fun loadAllTopicsByQuery(query: String): Result> = + override suspend fun loadTopicsByQuery(query: String): Result> = withContext(Dispatchers.Default) { try { val documentSnapshots = firestore - .collection(TopicItem.collectionName) + .collection(TopicItem.defaultCollectionName) .whereEqualTo("topicName", query) .get() .await() diff --git a/app/src/main/java/com/kdbrian/sage/di/documentModules.kt b/app/src/main/java/com/kdbrian/sage/di/documentModules.kt new file mode 100644 index 0000000..78d882d --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/documentModules.kt @@ -0,0 +1,15 @@ +package com.kdbrian.sage.di + +import com.kdbrian.sage.data.remote.impl.DocumentRepoImpl +import com.kdbrian.sage.domain.repo.DocumentRepo +import org.koin.dsl.module + +val documentModules = module { + + single { + DocumentRepoImpl( + firebaseFirestore = get() + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/homeModules.kt b/app/src/main/java/com/kdbrian/sage/di/homeModules.kt new file mode 100644 index 0000000..4ed628d --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/homeModules.kt @@ -0,0 +1,10 @@ +package com.kdbrian.sage.di + +import com.kdbrian.sage.ui.state.HomeScreenViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val homeModules = module { + + viewModelOf(::HomeScreenViewModel) +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/topicModules.kt b/app/src/main/java/com/kdbrian/sage/di/topicModules.kt index 616a7fb..560be90 100644 --- a/app/src/main/java/com/kdbrian/sage/di/topicModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/topicModules.kt @@ -2,6 +2,8 @@ package com.kdbrian.sage.di import com.kdbrian.sage.data.remote.impl.TopicRepoImpl import com.kdbrian.sage.domain.repo.TopicRepo +import com.kdbrian.sage.ui.state.TopicDetailsViewModel +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val topicModules = module { @@ -12,5 +14,7 @@ val topicModules = module { ) } + viewModelOf(::TopicDetailsViewModel) + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt new file mode 100644 index 0000000..2c39f49 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt @@ -0,0 +1,29 @@ +package com.kdbrian.sage.domain.model + +import com.google.firebase.events.Publisher +import com.kdbrian.sage.BuildConfig +import kotlinx.serialization.MetaSerializable +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class DocumentModel( + val documentId: String = UUID.randomUUID().toString(), + val documentName: String = "", + val publicationAt: String = "", + val topics: List = emptyList(), + val publisher: String = "",//not firebase uid but will be loaded into user account and offloaded + val summary: String = "", + val isAiSummarizable: String = "", + val fileInfo: String = "", + val aiSummary: String = "", + val uri: String = "", +) { + companion object { + const val defaultCollectionName = + "${BuildConfig.APPLICATION_ID}/documents_meta/default" //our inventory + const val generatedCollectionName = + "${BuildConfig.APPLICATION_ID}/documents_meta/uploaded"//uploaded by user + } + +} diff --git a/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt index 7eb53c8..bf7588d 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt @@ -10,6 +10,7 @@ data class TopicItem( val dateAdded: Long = System.currentTimeMillis(), ){ companion object{ - const val collectionName = "${BuildConfig.APPLICATION_ID}/topics/default" + const val defaultCollectionName = "${BuildConfig.APPLICATION_ID}/topics/default" + const val generatedCollectionName = "${BuildConfig.APPLICATION_ID}/topics/gen_ai" } } diff --git a/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt new file mode 100644 index 0000000..68f18f6 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt @@ -0,0 +1,20 @@ +package com.kdbrian.sage.domain.repo + +import com.kdbrian.sage.domain.model.DocumentModel + +interface DocumentRepo { + + suspend fun loadDefaultDocumentsAll() : Result> + + suspend fun loadUploadedDocumentsAll() : Result> + + suspend fun loadDefaultDocumentsByTopic(topicId: String) : Result> + + suspend fun loadUploadedDocumentsByTopic(topicId: String) : Result> + + suspend fun loadDefaultDocumentsByQuery(query: String) : Result> + + suspend fun loadUploadedDocumentsByQuery(query: String) : Result> + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt index 168901e..ef4e0a3 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt @@ -3,6 +3,8 @@ package com.kdbrian.sage.domain.repo import com.kdbrian.sage.domain.model.TopicItem interface TopicRepo { - suspend fun loadAllTopics(): Result> - suspend fun loadAllTopicsByQuery(query: String): Result> + + suspend fun loadTopicInfoById(topicId: String): Result + suspend fun loadDefaultTopics(): Result> + suspend fun loadTopicsByQuery(query: String): Result> } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt index c1bdb4a..749fb04 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -8,6 +8,9 @@ import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -18,16 +21,19 @@ import com.kdbrian.sage.ui.screens.GetStarted import com.kdbrian.sage.ui.screens.HomeScreen import com.kdbrian.sage.ui.screens.ProfileScreen import com.kdbrian.sage.ui.screens.TopicDetails +import com.kdbrian.sage.ui.state.TopicDetailsViewModel +import org.koin.compose.viewmodel.koinViewModel @Composable fun MainNav() { + val navController = rememberNavController() + val topicDetailsViewModel = koinViewModel() NavHost( modifier = Modifier - .fillMaxSize() - , + .fillMaxSize(), navController = navController, startDestination = HomeScreenRoute ) { @@ -38,7 +44,7 @@ fun MainNav() { navController.navigate(ProfileRoute) }, onTopicExpand = { - navController.navigate(TopicDetailsRoute) + navController.navigate(TopicDetailsRoute(it)) }, onOpenFYP = { } @@ -47,7 +53,14 @@ fun MainNav() { composable { + val detailsRoute = it.toRoute() + + LaunchedEffect(Unit) { + topicDetailsViewModel.loadTopicDetails(detailsRoute.topicId) + } + TopicDetails( + topicDetailsViewModel = topicDetailsViewModel, navController = navController, onClose = { navController.popBackStack() } ) diff --git a/app/src/main/java/com/kdbrian/sage/nav/Route.kt b/app/src/main/java/com/kdbrian/sage/nav/Route.kt index 6b3b373..a26aba2 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/Route.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/Route.kt @@ -19,7 +19,7 @@ data object CreateScreenRoute : Route() @Serializable -data object TopicDetailsRoute : Route() +data class TopicDetailsRoute(val topicId : String) : Route() @Serializable diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt index c256c38..13ddc49 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -44,7 +44,7 @@ fun RoundedInputField( unfocusedTextColor = Color.Black, cursorColor = Color.Black, unfocusedLeadingIconColor = Color.White, - focusedLeadingIconColor = Color.White, + focusedLeadingIconColor = Color.Black, ) ) { diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt index 8fef508..e356786 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt @@ -1,60 +1,80 @@ package com.kdbrian.sage.ui.composables import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp import com.kdbrian.sage.ui.theme.SageTheme @Composable fun TopicCard( modifier: Modifier = Modifier, - color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surface, - contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, +// color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surface, +// contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, text: AnnotatedString = buildAnnotatedString { - append("Topic") + append(LoremIpsum(2).values.joinToString()) }, onClick: () -> Unit = {} ) { Surface( - onClick = onClick, - color = color, - contentColor = contentColor, + modifier = modifier.padding(4.dp), shadowElevation = 2.dp, - modifier = modifier - .requiredSize(100.dp), - shape = RoundedCornerShape(24.dp) + onClick = onClick, + shape = RoundedCornerShape(50) ) { Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + modifier = modifier ) { Text( text = text, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + textAlign = TextAlign.Center ) - } - + } } } -@Preview +@Preview( + showBackground = true, +) @Composable private fun TopicCardPrev() { SageTheme { - TopicCard() + LazyHorizontalStaggeredGrid( + rows = StaggeredGridCells.Fixed(3), + modifier = Modifier.fillMaxWidth() + .requiredHeightIn(max = 130.dp) + ) { + items(10){index-> + key(index){ + TopicCard() + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt index bed4bed..4fe4bf4 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.navigation.NavHostController @@ -16,18 +18,22 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.kdbrian.sage.nav.CreateScreenRoute import com.kdbrian.sage.nav.HomeScreenRoute +import com.kdbrian.sage.ui.state.HomeScreenViewModel import com.kdbrian.sage.ui.theme.SageTheme import com.kdbrian.sage.ui.util.BottomBarItem +import org.koin.compose.viewmodel.koinViewModel @Composable fun HomeScreen( navHostController: NavHostController = rememberNavController(), onOpenProfile : ()-> Unit, - onTopicExpand : ()-> Unit, + onTopicExpand : (String)-> Unit, onOpenFYP: () -> Unit = {} ) { + val homeScreenViewModel = koinViewModel() + val uiState by homeScreenViewModel.uiState.collectAsState() val backStackEntryAsState = navHostController.currentBackStackEntryAsState() Scaffold( @@ -37,7 +43,13 @@ fun HomeScreen( NavigationBarItem( selected = backStackEntryAsState.value?.destination?.route == item.route.toString(), onClick = { - navHostController.navigate(item.route) + navHostController.navigate(item.route){ + popUpTo(HomeScreenRoute){ + saveState = true + } + launchSingleTop = true + restoreState = true + } }, icon = { Icon(imageVector = item.icon, contentDescription = null) @@ -57,6 +69,7 @@ fun HomeScreen( ) { composable { LandingPage( + uiState = uiState, onOpenProfile = onOpenProfile, onOpenTopic = onTopicExpand, openFYP = onOpenFYP diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt index 0eb6bc0..7f8d3cc 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt @@ -1,6 +1,7 @@ package com.kdbrian.sage.ui.screens import android.icu.text.StringSearch +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,220 +15,361 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.BookmarkAdded +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.KeyboardArrowLeft +import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.kdbrian.sage.App import com.kdbrian.sage.R import com.kdbrian.sage.ui.composables.CategoryItem import com.kdbrian.sage.ui.composables.CircularProfileAvatar import com.kdbrian.sage.ui.composables.RoundedInputField import com.kdbrian.sage.ui.composables.TopicCard +import com.kdbrian.sage.ui.state.HomeScreenUiState +import com.kdbrian.sage.util.AppDataStore +import kotlinx.coroutines.flow.first +import org.koin.compose.koinInject +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LandingPage( + uiState: HomeScreenUiState, onOpenProfile: () -> Unit = {}, - onOpenTopic: () -> Unit = {}, + onOpenTopic: (String) -> Unit = {}, openFYP: () -> Unit = {} ) { - Column( - modifier = Modifier.Companion - .fillMaxSize() - ) { - Column( - modifier = Modifier.padding(4.dp) - ) { - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - - CircularProfileAvatar( - onImageClick = onOpenProfile - ) + val appDataStore = koinInject() - Spacer(Modifier.padding(12.dp)) + var isExpanded by remember { + mutableStateOf(false) + } + LaunchedEffect(Unit) { + isExpanded = appDataStore.firstTime.first() + } - RoundedInputField( - placeholder = "Search", - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Search, - contentDescription = null + Scaffold( + modifier = Modifier + .fillMaxSize(), + topBar = { + Surface { + TopAppBar( + modifier = Modifier, + title = { + BasicText( + text = "Hello,", + overflow = TextOverflow.Ellipsis, + maxLines = 1, + autoSize = TextAutoSize.StepBased( + maxFontSize = 36.sp, + minFontSize = 32.sp, + stepSize = (2.75).sp, + ), + style = MaterialTheme.typography.displaySmall, + ) + }, + navigationIcon = { + CircularProfileAvatar( + modifier = Modifier.padding(8.dp), + onImageClick = onOpenProfile ) }, + actions = { + AnimatedContent( + targetState = isExpanded, + modifier = Modifier.padding(6.dp) + ) { + + if (!it) { + Surface( + shape = CircleShape, + shadowElevation = 3.dp, + onClick = { isExpanded = true } + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, + contentDescription = null, + ) + } + } else { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Surface( + shape = CircleShape, + shadowElevation = 3.dp, + onClick = { isExpanded = false } + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null + ) + } + + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Rounded.Schedule, + contentDescription = null + ) + } + + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Rounded.Favorite, +// tint = Color.Red, + contentDescription = null + ) + } + + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Rounded.BookmarkAdded, +// tint = Color.Green, + contentDescription = null + ) + } + } + + + } + + + } - ) + } + ) } + } + ) { pd -> - /*val scrollState = rememberScrollState() - Row( - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - .padding(horizontal = 12.dp) - .horizontalScroll(scrollState), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - CategoryItem(text = "Music") + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(pd) + .fillMaxSize() + ) { - CategoryItem(text = "Science") + Column { - CategoryItem(text = "Finance") + Text( + text = "Explore", + modifier = Modifier.padding(top = 12.dp, start = 12.dp), + style = MaterialTheme.typography.titleLarge + .copy(fontWeight = FontWeight.Bold) + ) - CategoryItem(text = "Lifestyle") + LazyHorizontalStaggeredGrid( + rows = StaggeredGridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .padding(horizontal = 12.dp) + .requiredHeightIn(max = 180.dp), + ) { + if (uiState.isLoading) { + items(12) { + TopicCard( + modifier = Modifier + .padding(4.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.LightGray), + text = buildAnnotatedString { append(" ") }, + ) + } + } else { + itemsIndexed(uiState.topics) { index, topic -> + key(index) { + TopicCard( + modifier = Modifier + .padding(4.dp), + text = buildAnnotatedString { + append(topic?.topicName ?: "") + }, + onClick = { + onOpenTopic(topic?.topicId ?: "Invalid") + } + ) + } + } + } + } + } - CategoryItem(text = "Sports") +// Spacer(Modifier.weight(1f)) - }*/ + RoundedInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + fieldState = uiState.searchQuery, + placeholder = "Search", + ) - } +// Spacer(Modifier.weight(1f)) + Column { - Text( - text = "Explore", - modifier = Modifier.padding(top = 12.dp, start = 12.dp), - style = MaterialTheme.typography.titleLarge - ) - - LazyHorizontalGrid( - rows = GridCells.Fixed(3), - modifier = Modifier.Companion - .fillMaxWidth() - .padding(top = 12.dp) - .padding(horizontal = 12.dp) - .height(330.dp), - ) { - items(12) { - TopicCard( - modifier = Modifier.Companion.padding(4.dp), - text = buildAnnotatedString { append("Topic $it") }, - onClick = onOpenTopic + Text( + text = "Documents For You", + modifier = Modifier.padding(top = 12.dp, start = 12.dp), + style = MaterialTheme.typography.titleLarge + .copy(fontWeight = FontWeight.Bold) ) - } - } - Spacer(Modifier.Companion.height(24.dp)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .padding(horizontal = 12.dp) + .requiredHeightIn( + max = 200.dp, + min = 180.dp + ), + contentColor = Color.White, + shape = RoundedCornerShape(24.dp), + shadowElevation = 3.dp, + ) { - Text( - text = "Documents For You", - modifier = Modifier.padding(top = 12.dp, start = 12.dp), - style = MaterialTheme.typography.titleLarge - ) + AsyncImage( + contentScale = ContentScale.Crop, + contentDescription = null, + model = null, + error = painterResource(R.drawable.paper_avalanche), + modifier = Modifier.fillMaxSize() + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black//.copy(alpha = 0.85f) + ) + ) + ), + content = {}, + ) - Surface( - modifier = Modifier.Companion - .fillMaxWidth() - .padding(top = 12.dp) - .padding(horizontal = 12.dp) - .requiredHeightIn( - max = 200.dp, - min = 180.dp - ), - contentColor = Color.Companion.White, - shape = RoundedCornerShape(24.dp), - shadowElevation = 3.dp, - ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.Start, + ) { +// Spacer(Modifier.weight(1f)) + + Text( + text = "For You", + style = MaterialTheme.typography.displaySmall + ) - AsyncImage( - contentScale = ContentScale.Companion.Crop, - contentDescription = null, - model = null, - error = painterResource(R.drawable.paper_avalanche), - modifier = Modifier.Companion.fillMaxSize() - ) - Box( - modifier = Modifier.Companion - .fillMaxSize() - .background( - brush = Brush.Companion.verticalGradient( - colors = listOf( - Color.Companion.Transparent, - Color.Companion.Black//.copy(alpha = 0.85f) + Text( + text = "A collection of what you may like. We keep it updated with every new document you like", + style = MaterialTheme.typography.labelMedium.copy( + textAlign = TextAlign.Start, + fontWeight = FontWeight.Light ) ) - ), - content = {}, - ) - Column( - modifier = Modifier.Companion - .fillMaxSize() - .padding(20.dp), - horizontalAlignment = Alignment.Companion.Start, - ) { - Spacer(Modifier.Companion.weight(1f)) - - Text( - text = "For You", - style = MaterialTheme.typography.displaySmall - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + CategoryItem( + text = "explore", + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = null, + modifier = Modifier.size(15.dp) + ) + }, + onClick = openFYP + ) + TextButton(onClick = {}) { + Text(text = "Shared Documents") + } + } - Text( - text = "A collection of what you may like. We keep it updated with every new document you like", - style = MaterialTheme.typography.labelMedium.copy( - textAlign = TextAlign.Companion.Start, - fontWeight = FontWeight.Companion.Light - ) - ) - Row( - verticalAlignment = Alignment.Companion.CenterVertically, - modifier = Modifier.Companion - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - CategoryItem( - text = "explore", - icon = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = null, - modifier = Modifier.Companion.size(15.dp) - ) - }, - onClick = openFYP - ) - - TextButton(onClick = {}) { - Text(text = "Shared Documents") } - } + } } } + } +} +@Preview +@Composable +private fun LandingPagePrev() { + App { + LandingPage( + uiState = HomeScreenUiState() + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt index 3d0d553..be95fd8 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt @@ -21,32 +21,54 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController import com.kdbrian.sage.App import com.kdbrian.sage.nav.DocumentDetailsRoute import com.kdbrian.sage.ui.composables.ListItemPreview +import com.kdbrian.sage.ui.state.TopicDetailsUiState +import com.kdbrian.sage.ui.state.TopicDetailsViewModel +import com.kdbrian.sage.util.AppDataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopicDetails( + topicDetailsViewModel: TopicDetailsViewModel = koinViewModel(), navController: NavHostController, onClose: () -> Unit = {} ) { + val uiState by topicDetailsViewModel.uiState.collectAsState() - var isFavorite by remember { - mutableStateOf(false) + val isFavorite by remember { + derivedStateOf { + uiState.favouriteTopics.any { + it == uiState.topicDetails?.topicId + } + } } val icon = remember { @@ -67,7 +89,27 @@ fun TopicDetails( CenterAlignedTopAppBar( title = { Text( - text = "Topic Details" + text = buildAnnotatedString { + withStyle( + SpanStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + ) { + append("Topic Details\n") + } + + withStyle( + SpanStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = Color.LightGray + ) + ) { + append(uiState.topicDetails?.topicName ?: "") + } + }, + textAlign = TextAlign.Center ) }, navigationIcon = { @@ -87,9 +129,7 @@ fun TopicDetails( ) - IconButton(onClick = { - isFavorite = !isFavorite - }) { + IconButton(onClick = topicDetailsViewModel::onFavourite) { Icon( tint = tint, imageVector = icon.value, diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt index 62e1de9..4301ebe 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt @@ -1,8 +1,11 @@ package com.kdbrian.sage.ui.state import androidx.compose.foundation.text.input.TextFieldState +import com.kdbrian.sage.domain.model.TopicItem data class HomeScreenUiState( val searchQuery: TextFieldState = TextFieldState(), - val topics: List = emptyList() + val topics: List = emptyList(), + val isLoading: Boolean = false, + val message: String = "" ) \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt index b38d75b..f3e021f 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt @@ -2,19 +2,55 @@ package com.kdbrian.sage.ui.state import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kdbrian.sage.domain.repo.TopicRepo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber -class HomeScreenViewModel : ViewModel() { - +class HomeScreenViewModel( + private val topicRepo: TopicRepo +) : ViewModel() { private val _mutableState: MutableStateFlow = MutableStateFlow(HomeScreenUiState()) - private val uiState = _mutableState.asStateFlow() + val uiState = _mutableState.asStateFlow() fun updateQuery(query: String) { _mutableState.value.searchQuery.setTextAndPlaceCursorAtEnd(query) } + init { + loadDefaultTopics() + } + + fun loadDefaultTopics() { + viewModelScope.launch { + + _mutableState.value = _mutableState.value.copy(isLoading = true) + + val allTopics = topicRepo.loadDefaultTopics() + + allTopics.onSuccess { + Timber.d("done ${it.size}") + _mutableState.value = _mutableState.value.copy( + topics = it, + isLoading = false, + message = "Loaded ${it.size} topics." + ) + } + + allTopics.onFailure { + Timber.d("Failed ${it.message}") + _mutableState.value = _mutableState.value.copy( + message = it.message ?: "Something went wrong : ${it.message}", + isLoading = false + ) + } + + + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt new file mode 100644 index 0000000..a8c5899 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt @@ -0,0 +1,94 @@ +package com.kdbrian.sage.ui.state + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kdbrian.sage.domain.model.DocumentModel +import com.kdbrian.sage.domain.model.TopicItem +import com.kdbrian.sage.domain.repo.DocumentRepo +import com.kdbrian.sage.domain.repo.TopicRepo +import com.kdbrian.sage.util.AppDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber + +data class TopicDetailsUiState( + val isLoading: Boolean = false, + val topicDetails: TopicItem? = null, + val message: String? = null, + val topicDocuments: List = emptyList(), + val favouriteTopics: MutableSet = mutableSetOf() +) + + +class TopicDetailsViewModel( + private val topicRepo: TopicRepo, + private val appDataStore: AppDataStore, + private val documentRepo: DocumentRepo +) : ViewModel() { + + private val _mutableState = MutableStateFlow(TopicDetailsUiState()) + val uiState = _mutableState.asStateFlow() + + init { + loadFavouriteTopics() + } + + + fun loadFavouriteTopics() { + viewModelScope.launch { + appDataStore.favouriteTopics.collect { + _mutableState.value = _mutableState.value.copy(favouriteTopics = it) + } + } + } + + fun loadTopicDetails(topicId: String) { + + viewModelScope.launch { + _mutableState.value = _mutableState.value.copy(isLoading = true) + val topicInfoById = topicRepo.loadTopicInfoById(topicId) + + topicInfoById.onSuccess { + _mutableState.value = _mutableState.value.copy(topicDetails = it) + + launch { + _mutableState.value = _mutableState.value.copy(isLoading = true) + Timber.d("invoked. ${it?.topicName}") + + val defaultDocumentsByTopic = documentRepo.loadDefaultDocumentsByTopic(topicId) + + defaultDocumentsByTopic.onSuccess { + _mutableState.value = + _mutableState.value.copy(topicDocuments = it, isLoading = false) + } + + defaultDocumentsByTopic.onFailure { + _mutableState.value = + _mutableState.value.copy(isLoading = false, message = it.message) + } + } + } + + + topicInfoById.onFailure { + _mutableState.value = _mutableState.value.copy(isLoading = false) + } + + } + } + + + fun onFavourite() { + viewModelScope.launch { + Timber.d("before : ${appDataStore.favouriteTopics.first().size}") + _mutableState.value.topicDetails?.topicId?.let { + appDataStore.favouriteTopics.first().add(it) + } + + Timber.d("updated : ${appDataStore.favouriteTopics.first().size}") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt index c375642..f7444ea 100644 --- a/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt +++ b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt @@ -3,8 +3,11 @@ package com.kdbrian.sage.util import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.kdbrian.sage.domain.model.TopicItem import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json val Context.appDataStore by preferencesDataStore(name = "app_data") @@ -14,7 +17,8 @@ class AppDataStore( private val context: Context ) { - private val _isFirstTime = booleanPreferencesKey(AppDatastoreKeys.firstTime) + //first time + private val _isFirstTime = booleanPreferencesKey(Companion.firstTime) suspend fun updateFirstTime(firstTime: Boolean) { context.appDataStore.edit { preferences -> preferences[_isFirstTime] = firstTime @@ -25,9 +29,22 @@ class AppDataStore( preferences[_isFirstTime] ?: true } + //fav topics + private val _favouriteTopics = stringPreferencesKey(Companion.favouriteTopics) +// suspend fun updateFavouriteTopics(favouriteTopics: Set) { +// context.appDataStore.edit { preferences -> +// preferences[_favouriteTopics] = Json.encodeToString(favouriteTopics) +// } +// } + val favouriteTopics = context.appDataStore.data.map { preferences -> + val decodeFromString = + Json.decodeFromString>(preferences[_favouriteTopics] ?: "[]") + decodeFromString.toMutableSet() + } - object AppDatastoreKeys { - const val firstTime = "isFirstTime" + companion object { + private const val firstTime = "isFirstTime" + private const val favouriteTopics = "favouriteTopics" } } \ No newline at end of file From cc4303e93ca9da712a1390eceb6a35731920d1ae Mon Sep 17 00:00:00 2001 From: kidiga Date: Sat, 9 Aug 2025 13:44:04 +0300 Subject: [PATCH 20/25] refactor/ - finished off landing page ui - finished off topic details page ui --- .../sage/ui/composables/RoundedInputField.kt | 2 +- .../kdbrian/sage/ui/screens/LandingPage.kt | 138 ++++++++++-------- .../kdbrian/sage/ui/screens/TopicDetails.kt | 2 +- .../sage/ui/state/TopicDetailsViewModel.kt | 13 +- .../com/kdbrian/sage/util/AppDataStore.kt | 17 ++- 5 files changed, 103 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt index 13ddc49..7520772 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -33,7 +33,7 @@ fun RoundedInputField( colors: TextFieldColors = TextFieldDefaults.colors( unfocusedContainerColor = Color.LightGray, unfocusedPlaceholderColor = Color.White, - focusedPlaceholderColor = Color.White, + focusedPlaceholderColor = Color.LightGray, focusedContainerColor = Color.White, unfocusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt index 7f8d3cc..c876645 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid @@ -25,6 +26,8 @@ import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft @@ -59,6 +62,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -99,6 +103,7 @@ fun LandingPage( isExpanded = appDataStore.firstTime.first() } + val focusManager = LocalFocusManager.current Scaffold( modifier = Modifier .fillMaxSize(), @@ -203,64 +208,6 @@ fun LandingPage( .fillMaxSize() ) { - Column { - - Text( - text = "Explore", - modifier = Modifier.padding(top = 12.dp, start = 12.dp), - style = MaterialTheme.typography.titleLarge - .copy(fontWeight = FontWeight.Bold) - ) - - LazyHorizontalStaggeredGrid( - rows = StaggeredGridCells.Fixed(3), - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .padding(horizontal = 12.dp) - .requiredHeightIn(max = 180.dp), - ) { - if (uiState.isLoading) { - items(12) { - TopicCard( - modifier = Modifier - .padding(4.dp) - .clip(RoundedCornerShape(24.dp)) - .background(Color.LightGray), - text = buildAnnotatedString { append(" ") }, - ) - } - } else { - itemsIndexed(uiState.topics) { index, topic -> - key(index) { - TopicCard( - modifier = Modifier - .padding(4.dp), - text = buildAnnotatedString { - append(topic?.topicName ?: "") - }, - onClick = { - onOpenTopic(topic?.topicId ?: "Invalid") - } - ) - } - } - } - } - } - -// Spacer(Modifier.weight(1f)) - - RoundedInputField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - fieldState = uiState.searchQuery, - placeholder = "Search", - ) - -// Spacer(Modifier.weight(1f)) - Column { Text( @@ -276,8 +223,8 @@ fun LandingPage( .padding(top = 12.dp) .padding(horizontal = 12.dp) .requiredHeightIn( - max = 200.dp, - min = 180.dp + max = 170.dp, + min = 150.dp ), contentColor = Color.White, shape = RoundedCornerShape(24.dp), @@ -360,6 +307,77 @@ fun LandingPage( } + RoundedInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp), + fieldState = uiState.searchQuery, + placeholder = "Search", + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null + ) + }, + keyboardOptions = KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Search, + capitalization = androidx.compose.ui.text.input.KeyboardCapitalization.Words + ), + keyBoardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + } + ) + ) + + + Column { + + Text( + text = "Explore", + modifier = Modifier.padding(top = 12.dp, start = 12.dp), + style = MaterialTheme.typography.titleLarge + .copy(fontWeight = FontWeight.Bold) + ) + + LazyHorizontalStaggeredGrid( + rows = StaggeredGridCells.Fixed(4), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .padding(horizontal = 12.dp) + .requiredHeightIn(max = 250.dp), + ) { + if (uiState.isLoading) { + items(20) { + TopicCard( + modifier = Modifier + .width(100.dp) + .padding(4.dp), + text = buildAnnotatedString { append(" ") }, + ) + } + } else { + itemsIndexed(uiState.topics) { index, topic -> + key(index) { + TopicCard( + modifier = Modifier + .padding(4.dp), + text = buildAnnotatedString { + append(topic?.topicName ?: "") + }, + onClick = { + onOpenTopic(topic?.topicId ?: "Invalid") + } + ) + } + } + } + } + } + + + } } } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt index be95fd8..bdb00ed 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -128,7 +129,6 @@ fun TopicDetails( Color.Black ) - IconButton(onClick = topicDetailsViewModel::onFavourite) { Icon( tint = tint, diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt index a8c5899..a7f5e7b 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt @@ -83,12 +83,21 @@ class TopicDetailsViewModel( fun onFavourite() { viewModelScope.launch { Timber.d("before : ${appDataStore.favouriteTopics.first().size}") - _mutableState.value.topicDetails?.topicId?.let { - appDataStore.favouriteTopics.first().add(it) + + uiState.value.topicDetails?.topicId?.let { + Timber.d("addin ") + appDataStore.updateFavouriteTopics(it) + _mutableState.value = _mutableState.value.copy(favouriteTopics = appDataStore.favouriteTopics.first()) } + Timber.d("updated : ${appDataStore.favouriteTopics.first().size}") } } + fun clear() { + _mutableState.value = TopicDetailsUiState() + } + + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt index f7444ea..198cb3a 100644 --- a/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt +++ b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt @@ -31,11 +31,18 @@ class AppDataStore( //fav topics private val _favouriteTopics = stringPreferencesKey(Companion.favouriteTopics) -// suspend fun updateFavouriteTopics(favouriteTopics: Set) { -// context.appDataStore.edit { preferences -> -// preferences[_favouriteTopics] = Json.encodeToString(favouriteTopics) -// } -// } + suspend fun updateFavouriteTopics(favouriteTopic: String) { + context.appDataStore.edit { preferences -> + //decode existing topics + val decodeFromString = Json.decodeFromString>(preferences[_favouriteTopics] ?: "[]") + + //add all new + val mutableSet = decodeFromString.toMutableSet() + mutableSet.add(favouriteTopic) + + preferences[_favouriteTopics] = Json.encodeToString(mutableSet) + } + } val favouriteTopics = context.appDataStore.data.map { preferences -> val decodeFromString = Json.decodeFromString>(preferences[_favouriteTopics] ?: "[]") From d3786cb73e0690ec3992d531df0939392e512e4b Mon Sep 17 00:00:00 2001 From: kidiga Date: Sat, 9 Aug 2025 17:14:54 +0300 Subject: [PATCH 21/25] feat/ - Implement CreateScreenViewModel for document creation. - Add document metadata extraction in FileUtils. - Integrate Firebase Storage for document uploads. - Enhance CreateScreen UI with validation and upload functionality. - Add utility function for displaying toasts. --- app/build.gradle.kts | 1 + app/src/main/java/com/kdbrian/sage/Sage.kt | 2 + .../sage/data/remote/impl/DocumentRepoImpl.kt | 50 +- .../kdbrian/sage/di/createDocumentModules.kt | 9 + .../com/kdbrian/sage/di/documentModules.kt | 3 +- .../com/kdbrian/sage/di/firebaseModules.kt | 5 + .../sage/domain/model/DocumentModel.kt | 2 +- .../kdbrian/sage/domain/repo/DocumentRepo.kt | 16 +- .../sage/ui/composables/RoundedInputField.kt | 6 +- .../kdbrian/sage/ui/screens/CreateScreen.kt | 482 ++++++++++++------ .../sage/ui/state/CreateScreenUiState.kt | 18 +- .../sage/ui/state/CreateScreenViewModel.kt | 148 +++++- .../java/com/kdbrian/sage/util/FileUtils.kt | 20 + .../main/java/com/kdbrian/sage/util/Utils.kt | 17 + gradle/libs.versions.toml | 2 + 15 files changed, 606 insertions(+), 175 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/di/createDocumentModules.kt create mode 100644 app/src/main/java/com/kdbrian/sage/util/Utils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3076204..b0ff51f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.firebase.analytics) implementation(libs.firebase.firestore) + implementation(libs.firebase.storage) testImplementation(libs.junit) testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter) diff --git a/app/src/main/java/com/kdbrian/sage/Sage.kt b/app/src/main/java/com/kdbrian/sage/Sage.kt index 15f3b45..a9a1c21 100644 --- a/app/src/main/java/com/kdbrian/sage/Sage.kt +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -3,6 +3,7 @@ package com.kdbrian.sage import android.app.Application import com.kdbrian.sage.di.analyticsModule import com.kdbrian.sage.di.coreModules +import com.kdbrian.sage.di.createDocumentModules import com.kdbrian.sage.di.documentModules import com.kdbrian.sage.di.firebaseModules import com.kdbrian.sage.di.homeModules @@ -27,6 +28,7 @@ class Sage : Application() { modules( analyticsModule, coreModules, + createDocumentModules, documentModules, firebaseModules, homeModules, diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt index 60ee8e0..c0d8846 100644 --- a/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt @@ -1,15 +1,63 @@ package com.kdbrian.sage.data.remote.impl +import android.net.Uri import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage import com.kdbrian.sage.domain.model.DocumentModel import com.kdbrian.sage.domain.repo.DocumentRepo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.UUID class DocumentRepoImpl( - private val firebaseFirestore: FirebaseFirestore + private val firebaseFirestore: FirebaseFirestore, + private val firebaseStorage: FirebaseStorage ) : DocumentRepo { + + + override suspend fun saveDocumentMetaData( + uri: Uri, + documentModel: DocumentModel + ): Result = + withContext(Dispatchers.Default) { + Timber.d("uri: $uri") + Timber.d("documentModel: $documentModel") + + try { + // Step 1: Create a new document reference + val documentReference = + firebaseFirestore.collection(DocumentModel.generatedCollectionName).document() + + // Step 2: Create a storage reference using the same document ID + val storageRef = firebaseStorage.reference + .child("${DocumentModel.generatedCollectionName}/files/${documentReference.id}") + + // Step 3: Upload file to Firebase Storage + storageRef.putFile(uri).await() + + // Step 4: Get the file's download URL + val downloadUrl = storageRef.downloadUrl.await().toString() + + + // Step 5: Save metadata in Firestore (including storage URL) + documentReference + .set( + documentModel.copy( + publisher = UUID.randomUUID().toString().split("-").first(), + documentId = documentReference.id, uri = downloadUrl + ) + ) + .await() + + Result.success(true) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun loadDefaultDocumentsAll(): Result> = withContext( Dispatchers.Default ) { diff --git a/app/src/main/java/com/kdbrian/sage/di/createDocumentModules.kt b/app/src/main/java/com/kdbrian/sage/di/createDocumentModules.kt new file mode 100644 index 0000000..adbcf47 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/createDocumentModules.kt @@ -0,0 +1,9 @@ +package com.kdbrian.sage.di + +import com.kdbrian.sage.ui.state.CreateScreenViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val createDocumentModules = module { + viewModelOf(::CreateScreenViewModel) +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/documentModules.kt b/app/src/main/java/com/kdbrian/sage/di/documentModules.kt index 78d882d..10c6c79 100644 --- a/app/src/main/java/com/kdbrian/sage/di/documentModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/documentModules.kt @@ -8,7 +8,8 @@ val documentModules = module { single { DocumentRepoImpl( - firebaseFirestore = get() + firebaseFirestore = get(), + firebaseStorage = get() ) } diff --git a/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt b/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt index 8718d0a..de67563 100644 --- a/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt @@ -3,6 +3,7 @@ package com.kdbrian.sage.di import android.content.Context import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage import org.koin.dsl.module val firebaseModules = module { @@ -16,4 +17,8 @@ val firebaseModules = module { FirebaseFirestore.getInstance() } + single { + FirebaseStorage.getInstance() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt index 2c39f49..b577eb3 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt @@ -15,7 +15,7 @@ data class DocumentModel( val publisher: String = "",//not firebase uid but will be loaded into user account and offloaded val summary: String = "", val isAiSummarizable: String = "", - val fileInfo: String = "", + var fileInfo: String = "", val aiSummary: String = "", val uri: String = "", ) { diff --git a/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt index 68f18f6..c927f64 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt @@ -1,20 +1,24 @@ package com.kdbrian.sage.domain.repo +import android.net.Uri import com.kdbrian.sage.domain.model.DocumentModel interface DocumentRepo { - suspend fun loadDefaultDocumentsAll() : Result> - suspend fun loadUploadedDocumentsAll() : Result> + suspend fun saveDocumentMetaData(uri: Uri, documentModel: DocumentModel): Result - suspend fun loadDefaultDocumentsByTopic(topicId: String) : Result> + suspend fun loadDefaultDocumentsAll(): Result> - suspend fun loadUploadedDocumentsByTopic(topicId: String) : Result> + suspend fun loadUploadedDocumentsAll(): Result> - suspend fun loadDefaultDocumentsByQuery(query: String) : Result> + suspend fun loadDefaultDocumentsByTopic(topicId: String): Result> - suspend fun loadUploadedDocumentsByQuery(query: String) : Result> + suspend fun loadUploadedDocumentsByTopic(topicId: String): Result> + + suspend fun loadDefaultDocumentsByQuery(query: String): Result> + + suspend fun loadUploadedDocumentsByQuery(query: String): Result> } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt index 7520772..b4bbd37 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -23,6 +23,7 @@ import com.kdbrian.sage.ui.theme.SageTheme fun RoundedInputField( modifier: Modifier = Modifier, singleLine: Boolean = true, + enabled: Boolean = true, fieldState: TextFieldState = rememberTextFieldState(), onValueChange: ((String) -> Unit)? = null, placeholder: String = "Placeholder", @@ -45,10 +46,13 @@ fun RoundedInputField( cursorColor = Color.Black, unfocusedLeadingIconColor = Color.White, focusedLeadingIconColor = Color.Black, - ) + ), + supportText : @Composable (() -> Unit)? = null ) { TextField( + supportingText = supportText, + enabled = enabled, value = fieldState.text.toString(), onValueChange = { onValueChange?.invoke(it) ?: fieldState.setTextAndPlaceCursorAtEnd(it) diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt index 534e6c1..503f56f 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -2,6 +2,7 @@ package com.kdbrian.sage.ui.screens import android.content.Intent import android.net.Uri +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -14,70 +15,84 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CopyAll import androidx.compose.material.icons.rounded.Upload import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.kdbrian.sage.ui.composables.CategoryItem import com.kdbrian.sage.ui.composables.RoundedInputField import com.kdbrian.sage.ui.composables.RowButton +import com.kdbrian.sage.ui.state.CreateScreenViewModel import com.kdbrian.sage.ui.theme.SageTheme import com.kdbrian.sage.util.FileUtils.getFileName -import kotlinx.coroutines.launch +import com.kdbrian.sage.util.Utils.displayToast +import org.koin.compose.viewmodel.koinViewModel +import timber.log.Timber @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateScreen() { +fun CreateScreen( + createScreenViewModel: CreateScreenViewModel = koinViewModel() +) { - val coroutineScope = rememberCoroutineScope() - var docUri by remember { - mutableStateOf(Uri.EMPTY) - } + val uiState by createScreenViewModel.uiState.collectAsState() val context = LocalContext.current - val bottomSheetState = rememberModalBottomSheetState() var isAddingTopic by remember { mutableStateOf(false) } - - val topics = remember { - mutableStateListOf() - } - LaunchedEffect(isAddingTopic) { if (!isAddingTopic) { bottomSheetState.hide() @@ -85,89 +100,149 @@ fun CreateScreen() { bottomSheetState.show() } } - val snackbarHostState = remember { SnackbarHostState() } - - - val pickDocument = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument() - ) { - it?.let { - context.contentResolver.takePersistableUriPermission( - it, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - docUri = it + val pickDocument = + rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { + it?.let { + context.contentResolver.takePersistableUriPermission( + it, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + createScreenViewModel.updateUri(it) + } } - } val iconAndText by remember { derivedStateOf { - if (docUri != Uri.EMPTY && docUri != null) { - context.getFileName(docUri!!) to Icons.Rounded.Upload + if (uiState.docUri != Uri.EMPTY && uiState.docUri != null) { + val fileName = context.getFileName(uiState.docUri!!) + fileName?.let { + val updatedName = if (it.endsWith(".pdf", true)) it.removeSuffix(".pdf") + else if (it.endsWith(".docx", true)) it.removeSuffix(".docx") + else it + updatedName to Icons.Rounded.Upload + } ?: ("" to Icons.Rounded.Add) } else { "Select Document." to Icons.Rounded.Add } } } + val scrollState = rememberScrollState() + val clipboardManager = LocalClipboardManager.current + //display message + val messages by createScreenViewModel.messages.collectAsState("") + + LaunchedEffect(messages) { + Timber.d("messages: $messages") + if (messages.isNotEmpty() && !uiState.isUploading) { + context.displayToast(message = messages) + } + } + + val focusManager = LocalFocusManager.current - Scaffold( - snackbarHost = { - androidx.compose.material3.SnackbarHost(hostState = snackbarHostState) - }, - modifier = Modifier.fillMaxSize() - ) { pd -> - val scrollState = rememberScrollState() - Box( + Box( + modifier = Modifier + .fillMaxSize() + ) { + Column( modifier = Modifier + .padding(16.dp) .fillMaxSize() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize() - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { + Text( + text = buildAnnotatedString { + + withStyle( + SpanStyle( + fontSize = MaterialTheme.typography.displayLarge.fontSize, + fontWeight = FontWeight.Bold, + color = Color.Blue.copy(alpha = 0.65f) + ) + ) { + append("C") + } - Column( - modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Text(text = "Create", style = MaterialTheme.typography.headlineLarge) - Text( - text = LoremIpsum(12).values.joinToString(), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Light, - textAlign = TextAlign.Center - ) - ) - } + withStyle( + SpanStyle( + fontSize = MaterialTheme.typography.displayMedium.fontSize, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift.Superscript, + textDecoration = TextDecoration.Underline + ) + ) { + append("reate\n") + } - Spacer(Modifier.height(24.dp)) - RowButton( - modifier = Modifier - .fillMaxWidth(), - leadingIcon = { - Row { - Icon( - imageVector = iconAndText.second, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp) + withStyle( + SpanStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + fontStyle = FontStyle.Italic, + ) + ) { + append("Documents that create ") + append("experiences.\n") + } + + + withStyle( + SpanStyle( + fontSize = 12.sp, + color = Color.LightGray, + fontWeight = FontWeight.SemiBold, + fontStyle = FontStyle.Italic ) - Text( - text = iconAndText.first ?: "Add Document", - style = MaterialTheme.typography.titleMedium + ) { + append("Allowed file types include ebooks, pdf & word\n") + } + + withStyle( + SpanStyle( + fontSize = 8.sp, + color = Color.Red, + fontWeight = FontWeight.Light, ) + ) { + append("Make sure you have the read terms and conditions section on file uploads.") } }, - onClick = { + style = TextStyle( + textAlign = TextAlign.Center + ), + modifier = Modifier.widthIn(max = 280.dp) + ) + } + + Spacer(Modifier.height(12.dp)) + + RowButton( + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Row { + Icon( + imageVector = iconAndText.second, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = iconAndText.first ?: "Add Document", + style = MaterialTheme.typography.titleMedium + ) + } + }, + onClick = { + if (!uiState.isUploading) pickDocument.launch( arrayOf( "application/pdf", @@ -175,49 +250,91 @@ fun CreateScreen() { "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) ) + }, + trailingIcon = { + AnimatedVisibility(uiState.docUri != null && uiState.docUri != Uri.EMPTY) { + + + IconButton(onClick = { + clipboardManager.setText( + buildAnnotatedString { + append(iconAndText.first) + } + ) + + createScreenViewModel.updateName(iconAndText.first) + + }) { + Icon(imageVector = Icons.Rounded.CopyAll, contentDescription = null) + } } - ) + } + ) - RoundedInputField( - modifier = Modifier - .fillMaxWidth(), - placeholder = "rename document" - ) + RoundedInputField( + enabled = !uiState.isUploading, + fieldState = uiState.documentName, + onValueChange = createScreenViewModel::updateName, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyBoardActions = KeyboardActions( + onNext = { + focusManager.moveFocus(FocusDirection.Down) + } + ), + placeholder = "rename document" + ) - RoundedInputField( - singleLine = false, - modifier = Modifier - .fillMaxWidth(), - placeholder = "Summary" - ) + RoundedInputField( + supportText = { + Text(text = "be brief.", style = MaterialTheme.typography.labelSmall) + }, + enabled = !uiState.isUploading, + onValueChange = createScreenViewModel::updateSummary, + fieldState = uiState.documentSummary, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + placeholder = "Summary", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyBoardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + } + ), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalAlignment = Alignment.Start + ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .padding(top = 12.dp), - horizontalAlignment = Alignment.Start + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = "Topics", style = MaterialTheme.typography.titleLarge) + Text(text = "Topics", style = MaterialTheme.typography.titleLarge) - CategoryItem(text = "Add", onClick = { - if (docUri == Uri.EMPTY || docUri == null) { - coroutineScope.launch { - snackbarHostState.showSnackbar("Please select a document") - } - } else - isAddingTopic = !isAddingTopic - }) - } - + TextButton(onClick = { + if (!uiState.isUploading) { + if (uiState.docUri == Uri.EMPTY || uiState.docUri == null) { + context.displayToast( + message = "Add a document first", + apply = { + duration = Toast.LENGTH_LONG + } + ) + } else isAddingTopic = !isAddingTopic + } + }) { + Text(text = "Add") + } } LazyHorizontalGrid( @@ -227,85 +344,126 @@ fun CreateScreen() { .padding(4.dp), rows = GridCells.Fixed(2), ) { - itemsIndexed(topics) { index, topic -> + Timber.d("uiState.topics.size : ${uiState.topics.size}") + itemsIndexed(uiState.topics.toList()) { index, topic -> key(index) { CategoryItem( - text = topic, - modifier = Modifier.padding(4.dp) + text = topic, modifier = Modifier.padding(4.dp), + onClick = { + clipboardManager.setText( + buildAnnotatedString { + append(topic) + } + ) + context.displayToast( + message = "Copied to clipboard.", + ) + createScreenViewModel.removeTopic(topic) + } ) } } } + } + + } + + AnimatedVisibility( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + visible = uiState.docUri != Uri.EMPTY && uiState.docUri != null && !uiState.isUploading + ) { + Button( + onClick = { + if (createScreenViewModel.validate()) { + createScreenViewModel.uploadDocument() + } + }, + modifier = Modifier.padding(8.dp), + shape = RoundedCornerShape(24.dp) + ) { + Text(text = "Upload") } - AnimatedVisibility( + } + + + AnimatedVisibility(uiState.isUploading, modifier = Modifier.align(Alignment.BottomCenter)) { + Surface( + shape = RoundedCornerShape(12.dp), modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), - visible = docUri != Uri.EMPTY && docUri != null + .fillMaxWidth() + .padding( + horizontal = 24.dp + ) ) { - Button( - onClick = {}, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(8.dp), - shape = RoundedCornerShape(24.dp) - ) { - Text(text = "Upload") + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + + Text( + text = messages, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Light, + fontSize = 12.sp + ) + ) + + CircularProgressIndicator( + modifier = Modifier.padding(8.dp), + ) + } } } } - if (isAddingTopic) { - val topicName = rememberTextFieldState() - ModalBottomSheet( - onDismissRequest = { - isAddingTopic = false - }, - sheetState = bottomSheetState + } + + if (isAddingTopic) { + val topicName = rememberTextFieldState() + + ModalBottomSheet( + onDismissRequest = { + isAddingTopic = false + }, sheetState = bottomSheetState + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = "Add Topic", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 12.dp) - ) + Text( + text = "Add Topic", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 12.dp) + ) - RoundedInputField( - fieldState = topicName, - modifier = Modifier.fillMaxWidth(), - placeholder = "Topic Name" - ) + RoundedInputField( + fieldState = topicName, + modifier = Modifier.fillMaxWidth(), + placeholder = "Topic Name" + ) - RowButton( - leadingIcon = { Text("Save") }, - modifier = Modifier.padding(top = 12.dp), - onClick = { - if (topics.size == 8) { - coroutineScope.launch { - snackbarHostState.showSnackbar("You can only add 8 topics.") - } - } else { - if (!topics.contains(topicName.text.trim().toString())) - topics.add(topicName.text.trim().toString()) - } - isAddingTopic = false - } - ) - } + RowButton( + leadingIcon = { Text("Save") }, + modifier = Modifier.padding(top = 12.dp), + onClick = { + createScreenViewModel.addTopic(topicName.text.trim().toString()) + topicName.clearText() + }) } } - } - } @Preview diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt index 7cf0b91..ecdda63 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt @@ -1,8 +1,22 @@ package com.kdbrian.sage.ui.state import android.net.Uri +import androidx.compose.foundation.text.input.TextFieldState +import androidx.core.net.toFile +import com.kdbrian.sage.domain.model.DocumentModel data class CreateScreenUiState( + val isUploading: Boolean = false, val docUri: Uri? = null, - val topics: List = emptyList() -) \ No newline at end of file + val documentName: TextFieldState = TextFieldState(), + val documentSummary: TextFieldState = TextFieldState(), + val topics: Set = mutableSetOf() +) + +fun CreateScreenUiState.copyDocumentModel() = + DocumentModel( + documentName = documentName.text.trim().toString(), + summary = documentSummary.text.trim().toString(), + topics = topics.toList(), + uri = docUri.toString() + ) \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt index 3c2e36b..df4cfe3 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt @@ -1,6 +1,152 @@ package com.kdbrian.sage.ui.state +import android.content.Context +import android.net.Uri +import android.view.textclassifier.ConversationActions +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kdbrian.sage.domain.repo.DocumentRepo +import com.kdbrian.sage.ui.screens.CreateScreen +import com.kdbrian.sage.util.FileUtils.getDocumentMetadataMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import androidx.core.net.toUri +import kotlinx.coroutines.delay + +class CreateScreenViewModel( + private val context: Context, + private val documentRepo: DocumentRepo, +) : ViewModel() { + + + private val _mutableState = MutableStateFlow(CreateScreenUiState()) + val uiState = _mutableState.asStateFlow() + + private val messageChannel = Channel() + val messages = messageChannel.receiveAsFlow() + + private val MessageUpdateTimeout = 3500L + + + fun validate(): Boolean { + + val coroutineScope = CoroutineScope(viewModelScope.coroutineContext) + + //doc uri != null || Uri.Empty + if (_mutableState.value.docUri == Uri.EMPTY || _mutableState.value.docUri == null) { + coroutineScope.launch { + messageChannel.send("Add a document first.") + } + return false + } + + //docName not empty + if (_mutableState.value.documentName.text.isEmpty()) { + coroutineScope.launch { + messageChannel.send("Document name cannot be empty.") + } + return false + } + + //summary not < 150 words + if (_mutableState.value.documentSummary.text.toString().length < 200) { + coroutineScope.launch { + messageChannel.send("Summary should be at least 200 letters.") + } + return false + } + + + //topics >= 4 + if (_mutableState.value.topics.size < 4) { + coroutineScope.launch { + messageChannel.send("Topics should be at least 4.") + } + return false + } + + return true + + } + + fun uploadDocument() { + viewModelScope.launch { + _mutableState.value= _mutableState.value.copy(isUploading = true) + messageChannel.send("validating document...") + + if (validate()) { + + messageChannel.send("uploading document...") + val documentModel = _mutableState.value.copyDocumentModel().apply { + fileInfo = context.getDocumentMetadataMap(uri.toUri()).toString() + } + + + val result = documentRepo.saveDocumentMetaData( + uri = _mutableState.value.docUri!!, + documentModel = documentModel + ) + + result.onSuccess { + messageChannel.send("document uploaded successfully.") + delay(MessageUpdateTimeout) + _mutableState.value = CreateScreenUiState() + } + + result.onFailure { + messageChannel.send("document uploaded failed ${it.message}") + delay(MessageUpdateTimeout) + _mutableState.value = _mutableState.value.copy(isUploading = false) + } + + }else + messageChannel.send("Fill all fields and retry.") + } + } + + fun updateName(name: String) { + _mutableState.value.documentName.setTextAndPlaceCursorAtEnd(name) + } + + fun updateSummary(summary: String) { + _mutableState.value.documentSummary.setTextAndPlaceCursorAtEnd(summary) + } + + fun addTopic(topic: String) { + _mutableState.value = _mutableState.value.copy( + topics = _mutableState.value.topics + topic + ) + } + + fun removeTopic(topic: String) { + _mutableState.value = _mutableState.value.copy( + topics = _mutableState.value.topics - topic + ) + } + + fun updateUri(uri: Uri) { + _mutableState.value = _mutableState.value.copy( + docUri = uri, + + ) + + } + + + override fun onCleared() { + super.onCleared() + clear() + } + + fun clear() { + _mutableState.value = CreateScreenUiState() + } + -class CreateScreenViewModel: ViewModel() { } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt b/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt index 1402f9e..581fd74 100644 --- a/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt +++ b/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt @@ -24,4 +24,24 @@ object FileUtils { return result } + + fun Context.getDocumentMetadataMap(uri: Uri): Map { + val metadata = mutableMapOf() + + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val columnNames = cursor.columnNames + if (cursor.moveToFirst()) { + columnNames.forEach { columnName -> + val value = cursor.getString(cursor.getColumnIndexOrThrow(columnName)) ?: "null" + metadata[columnName] = value + } + } + } + + return metadata + } + + + + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/util/Utils.kt b/app/src/main/java/com/kdbrian/sage/util/Utils.kt new file mode 100644 index 0000000..ea05138 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/Utils.kt @@ -0,0 +1,17 @@ +package com.kdbrian.sage.util + +import android.content.Context +import android.widget.Toast + +object Utils { + + fun Context.displayToast( + message: String, + apply: Toast.() -> Unit = {} + ) { + Toast.makeText(this, message, Toast.LENGTH_SHORT) + .apply { + apply() + }.show() + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cec9ec1..f36d294 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ timber = "5.0.1" googleGmsGoogleServices = "4.4.3" firebaseAnalytics = "23.0.0" firebaseFirestore = "26.0.0" +firebaseStorage = "22.0.0" [libraries] @@ -56,6 +57,7 @@ koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebaseAnalytics" } firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore", version.ref = "firebaseFirestore" } +firebase-storage = { group = "com.google.firebase", name = "firebase-storage", version.ref = "firebaseStorage" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From b0489170a6ded158c529f743fc95f72e2cae7848 Mon Sep 17 00:00:00 2001 From: kidiga Date: Sat, 9 Aug 2025 18:26:57 +0300 Subject: [PATCH 22/25] feat/ - Search results screen initial commit - Added simpler search in repo - Wired up search in landing page - SearchModule initial - ListItemPreview now takes in title and subtitle - Wired up onSearch in homescreen - SearchResultsUIState - wired up searchResultsScreen - Clears channel in CreateViewModel - Implemented simpler search in repo - Added searchModule to Sage - SearchResultsRoute --- app/src/main/java/com/kdbrian/sage/Sage.kt | 2 + .../sage/data/remote/impl/DocumentRepoImpl.kt | 36 ++++ .../java/com/kdbrian/sage/di/searchModules.kt | 9 + .../kdbrian/sage/domain/repo/DocumentRepo.kt | 1 + .../main/java/com/kdbrian/sage/nav/MainNav.kt | 32 ++- .../main/java/com/kdbrian/sage/nav/Route.kt | 4 + .../sage/ui/composables/ListItemPreview.kt | 47 +++-- .../com/kdbrian/sage/ui/screens/HomeScreen.kt | 10 +- .../kdbrian/sage/ui/screens/LandingPage.kt | 18 +- .../kdbrian/sage/ui/screens/SearchResults.kt | 190 ++++++++++++++++++ .../sage/ui/state/CreateScreenViewModel.kt | 1 + .../ui/state/SearchResultsScreenViewModel.kt | 110 ++++++++++ .../sage/ui/state/SearchResultsUiState.kt | 9 + 13 files changed, 445 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/di/searchModules.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/SearchResults.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsUiState.kt diff --git a/app/src/main/java/com/kdbrian/sage/Sage.kt b/app/src/main/java/com/kdbrian/sage/Sage.kt index a9a1c21..2bac18b 100644 --- a/app/src/main/java/com/kdbrian/sage/Sage.kt +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -7,6 +7,7 @@ import com.kdbrian.sage.di.createDocumentModules import com.kdbrian.sage.di.documentModules import com.kdbrian.sage.di.firebaseModules import com.kdbrian.sage.di.homeModules +import com.kdbrian.sage.di.searchModules import com.kdbrian.sage.di.topicModules import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger @@ -32,6 +33,7 @@ class Sage : Application() { documentModules, firebaseModules, homeModules, + searchModules, topicModules, ) } diff --git a/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt index c0d8846..28d690c 100644 --- a/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt @@ -1,6 +1,7 @@ package com.kdbrian.sage.data.remote.impl import android.net.Uri +import com.google.firebase.firestore.Filter import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.storage.FirebaseStorage import com.kdbrian.sage.domain.model.DocumentModel @@ -16,6 +17,41 @@ class DocumentRepoImpl( private val firebaseStorage: FirebaseStorage ) : DocumentRepo { + override suspend fun simplerSearch( + collectionName: String, + row: String, + rowValue: String, + filter: String + ): Result> = + withContext(Dispatchers.Default) { + try { + + val collectionReference = firebaseFirestore.collection(collectionName) + + val documentSnapshots = collectionReference + .where( + when (filter) { + "contains" -> Filter.or( + Filter.equalTo(row, rowValue), + Filter.arrayContains(row, rowValue) + ) + + "equals" -> Filter.equalTo(row, rowValue) + else -> Filter.equalTo(row, rowValue) + } + ).get() + .await() + + val documentModels = documentSnapshots.documents.map { + it.toObject(DocumentModel::class.java) + }.toList() + + Result.success(documentModels) + } catch (e: Exception) { + Result.failure(e) + } + } + override suspend fun saveDocumentMetaData( uri: Uri, diff --git a/app/src/main/java/com/kdbrian/sage/di/searchModules.kt b/app/src/main/java/com/kdbrian/sage/di/searchModules.kt new file mode 100644 index 0000000..94806a9 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/searchModules.kt @@ -0,0 +1,9 @@ +package com.kdbrian.sage.di + +import com.kdbrian.sage.ui.state.SearchResultsScreenViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val searchModules = module { + viewModelOf(::SearchResultsScreenViewModel) +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt index c927f64..552951a 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt @@ -5,6 +5,7 @@ import com.kdbrian.sage.domain.model.DocumentModel interface DocumentRepo { + suspend fun simplerSearch(collectionName : String = DocumentModel.generatedCollectionName, row : String, rowValue : String, filter: String): Result> suspend fun saveDocumentMetaData(uri: Uri, documentModel: DocumentModel): Result diff --git a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt index 749fb04..a49b11c 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -20,15 +20,19 @@ import com.kdbrian.sage.ui.screens.DocumentDetails import com.kdbrian.sage.ui.screens.GetStarted import com.kdbrian.sage.ui.screens.HomeScreen import com.kdbrian.sage.ui.screens.ProfileScreen +import com.kdbrian.sage.ui.screens.SearchResults import com.kdbrian.sage.ui.screens.TopicDetails +import com.kdbrian.sage.ui.state.SearchResultsScreenViewModel import com.kdbrian.sage.ui.state.TopicDetailsViewModel import org.koin.compose.viewmodel.koinViewModel +import timber.log.Timber @Composable fun MainNav() { val navController = rememberNavController() val topicDetailsViewModel = koinViewModel() + val searchResultsScreenViewModel = koinViewModel() NavHost( @@ -40,6 +44,9 @@ fun MainNav() { composable { HomeScreen( + onSearch = { + navController.navigate(SearchResultsRoute(it)) + }, onOpenProfile = { navController.navigate(ProfileRoute) }, @@ -51,7 +58,6 @@ fun MainNav() { ) } - composable { val detailsRoute = it.toRoute() @@ -85,6 +91,30 @@ fun MainNav() { GetStarted() } + composable { + val searchResultsRoute = it.toRoute() + Timber.d("query: ${searchResultsRoute.query}") + + LaunchedEffect(searchResultsRoute) { + searchResultsScreenViewModel.loadSearchResults(searchResultsRoute.query) + } + + + SearchResults( + searchResultsScreenViewModel = searchResultsScreenViewModel, + query = searchResultsRoute.query, + navController = navController + ) + } + + composable { + val detailsRoute = it.toRoute() + DocumentDetails( + navHostController = navController, + documentId = detailsRoute.docId + ) + } + } diff --git a/app/src/main/java/com/kdbrian/sage/nav/Route.kt b/app/src/main/java/com/kdbrian/sage/nav/Route.kt index a26aba2..5118231 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/Route.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/Route.kt @@ -29,3 +29,7 @@ data object ProfileRoute : Route() @Serializable data class DocumentDetailsRoute(val docId : String): Route() + +@Serializable +data class SearchResultsRoute(val query : String): Route() + diff --git a/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt index 2616505..ee41c0c 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt @@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -17,10 +19,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.datasource.LoremIpsum @@ -30,6 +34,19 @@ import com.kdbrian.sage.App @Composable fun ListItemPreview( modifier: Modifier = Modifier, + title: String = LoremIpsum(13).values.joinToString(), + subtitle: AnnotatedString = buildAnnotatedString { + append("Upto ") + withStyle( + SpanStyle( + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.SemiBold + ) + ) { + append("300 ") + } + append("documents.") + }, onClick: () -> Unit = {} ) { @@ -41,7 +58,7 @@ fun ListItemPreview( shape = RoundedCornerShape(16.dp), ) { Row( - modifier = Modifier.padding(4.dp), + modifier = Modifier.padding(6.dp), verticalAlignment = Alignment.Top ) { @@ -58,25 +75,21 @@ fun ListItemPreview( ) { - Text( - text = LoremIpsum(3).values.joinToString(), + BasicText( + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = Modifier.widthIn(max = 220.dp), + text = title, style = MaterialTheme.typography.titleMedium ) - Text( - text = buildAnnotatedString { - append("Upto ") - withStyle( - SpanStyle( - fontStyle = FontStyle.Italic, - fontWeight = FontWeight.SemiBold - ) - ) { - append("300 ") - } - append("documents.") - }, - style = MaterialTheme.typography.labelMedium + BasicText( + modifier = Modifier.widthIn(max = 180.dp).padding(4.dp), + text = subtitle, + maxLines = 1, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Light + ) ) } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt index 4fe4bf4..cad57c9 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -27,8 +27,9 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun HomeScreen( navHostController: NavHostController = rememberNavController(), - onOpenProfile : ()-> Unit, - onTopicExpand : (String)-> Unit, + onSearch: (String) -> Unit = {}, + onOpenProfile: () -> Unit, + onTopicExpand: (String) -> Unit, onOpenFYP: () -> Unit = {} ) { @@ -43,8 +44,8 @@ fun HomeScreen( NavigationBarItem( selected = backStackEntryAsState.value?.destination?.route == item.route.toString(), onClick = { - navHostController.navigate(item.route){ - popUpTo(HomeScreenRoute){ + navHostController.navigate(item.route) { + popUpTo(HomeScreenRoute) { saveState = true } launchSingleTop = true @@ -69,6 +70,7 @@ fun HomeScreen( ) { composable { LandingPage( + onSearch = onSearch, uiState = uiState, onOpenProfile = onOpenProfile, onOpenTopic = onTopicExpand, diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt index c876645..f55559f 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt @@ -1,5 +1,6 @@ package com.kdbrian.sage.ui.screens +import android.graphics.fonts.FontStyle import android.icu.text.StringSearch import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background @@ -89,6 +90,7 @@ import org.koin.compose.koinInject @Composable fun LandingPage( uiState: HomeScreenUiState, + onSearch: (String) -> Unit = {}, onOpenProfile: () -> Unit = {}, onOpenTopic: (String) -> Unit = {}, openFYP: () -> Unit = {} @@ -312,7 +314,19 @@ fun LandingPage( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 32.dp), fieldState = uiState.searchQuery, - placeholder = "Search", + placeholder = buildAnnotatedString { + append("topic:") + + withStyle( + SpanStyle( + fontWeight = FontWeight.SemiBold, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + ) + ) { + append("\"finance\"") + } + append(" or just \"finance\"") + }.toString(), leadingIcon = { Icon( imageVector = Icons.Rounded.Search, @@ -326,6 +340,7 @@ fun LandingPage( keyBoardActions = KeyboardActions( onSearch = { focusManager.clearFocus() + onSearch(uiState.searchQuery.text.toString()) } ) ) @@ -377,7 +392,6 @@ fun LandingPage( } - } } } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/SearchResults.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/SearchResults.kt new file mode 100644 index 0000000..ae0493d --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/SearchResults.kt @@ -0,0 +1,190 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.kdbrian.sage.App +import com.kdbrian.sage.nav.DocumentDetailsRoute +import com.kdbrian.sage.ui.composables.ListItemPreview +import com.kdbrian.sage.ui.state.SearchResultsScreenViewModel +import org.koin.compose.viewmodel.koinViewModel +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchResults( + query: String = LoremIpsum(2).values.joinToString(), + searchResultsScreenViewModel: SearchResultsScreenViewModel = koinViewModel(), + navController: NavHostController = rememberNavController() +) { + val uiState by searchResultsScreenViewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + val messages by searchResultsScreenViewModel.messages.collectAsState("") + + LaunchedEffect(messages) { + if (messages.isNotEmpty()) { + snackbarHostState.showSnackbar(messages) + } + } + + + + Scaffold( + snackbarHost = { + androidx.compose.material3.SnackbarHost(hostState = snackbarHostState) + }, + topBar = { + Surface( + shadowElevation = 3.dp + ) { + CenterAlignedTopAppBar( + title = { + Text( + text = buildAnnotatedString { + withStyle( + SpanStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + ) { + append("SearchResults\n") + } + + withStyle( + SpanStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = Color.LightGray + ) + ) { + append("for $query") + } + }, + textAlign = TextAlign.Center + ) + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + IconButton(onClick = { }) { + Icon( + imageVector = Icons.Rounded.Save, + contentDescription = null + ) + } + } + ) + } + } + ) { pd -> + LazyColumn( + modifier = Modifier + .padding(pd) + .padding(12.dp) + ) { + if (uiState.isLoading) { + items(5) { + ListItemPreview() + } + } else { + + Timber.d("document: ${uiState.documentsFromUploaded.size}") + + itemsIndexed(uiState.documentsFromUploaded) { index, document -> + key(index) { + ListItemPreview( + title = document?.documentName ?: "", + subtitle = buildAnnotatedString { + append(document?.topics?.joinToString(", ")) + }, + onClick = { + document?.documentId?.let { + navController.navigate(DocumentDetailsRoute(it)) + } + } + ) + } + } + } + + item { + Box( + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + HorizontalDivider( + modifier = Modifier.padding( + horizontal = 24.dp, + vertical = 6.dp + ) + ) + + TextButton(onClick = {}) { + Text(text = "More", style = MaterialTheme.typography.titleMedium) + } + + } + } + } + } + } + + +} + + +@Preview +@Composable +private fun SearchResultsPrev() { + App { + SearchResults() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt index df4cfe3..54412f7 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt @@ -146,6 +146,7 @@ class CreateScreenViewModel( fun clear() { _mutableState.value = CreateScreenUiState() + messageChannel.close() } diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt new file mode 100644 index 0000000..0e45efe --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt @@ -0,0 +1,110 @@ +package com.kdbrian.sage.ui.state + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kdbrian.sage.domain.repo.DocumentRepo +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import timber.log.Timber + + +class SearchResultsScreenViewModel( + private val documentRepo: DocumentRepo +) : ViewModel() { + + private val _mutableState = MutableStateFlow(SearchResultsUiState()) + val uiState = _mutableState.asStateFlow() + + private val messageChannel = Channel() + val messages = messageChannel.receiveAsFlow() + private val UITimeout = 1200L + + + fun loadSearchResults(query: String) { + viewModelScope.launch { + _mutableState.value = _mutableState.value.copy(isLoading = true) + + if (query.contains(":")) { + val row = query.split(":").first() + val rowValue = query.split(":").last() + + val simplerSearch = documentRepo.simplerSearch( + row = row, + rowValue = rowValue, + filter = "contains" + ) + + simplerSearch.fold( + onSuccess = { + Timber.tag("simplerSearch").d("received documents: $it") + + if (it.isEmpty()) { + messageChannel.send("no documents found.") + } else { + messageChannel.send("${it.size} documents loaded successfully.") + } + + delay(UITimeout) + + + _mutableState.value = _mutableState.value.copy( + isLoading = false, + documentsFromUploaded = it + ) + }, + onFailure = { + Timber.d("failed: ${it.message}") + messageChannel.send(it.message.toString()) + delay(UITimeout) + _mutableState.value = _mutableState.value.copy( + isLoading = false, + ) + } + ) + + } else { + val documentsByQuery = documentRepo.loadUploadedDocumentsByQuery(query = query) + + documentsByQuery.fold( + onSuccess = { + Timber.tag("documentsByQuery").d("received documents: $it") + if (it.isEmpty()) { + messageChannel.send("no documents found.") + } else { + messageChannel.send("${it.size} documents loaded successfully.") + } + delay(UITimeout) + _mutableState.value = _mutableState.value.copy( + isLoading = false, + documentsFromUploaded = it + ) + }, + onFailure = { + Timber.tag("documentsByQuery").d("failed: ${it.message}") + messageChannel.send(it.message.toString()) + delay(UITimeout) + _mutableState.value = _mutableState.value.copy( + isLoading = false, + ) + } + ) + + + } + + + } + + + } + + override fun onCleared() { + super.onCleared() + messageChannel.close() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsUiState.kt b/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsUiState.kt new file mode 100644 index 0000000..0f7bb37 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsUiState.kt @@ -0,0 +1,9 @@ +package com.kdbrian.sage.ui.state + +import com.kdbrian.sage.domain.model.DocumentModel + +data class SearchResultsUiState( + val isLoading: Boolean = false, + val documentsFromDefault: List = emptyList(), + val documentsFromUploaded: List = emptyList(), +) \ No newline at end of file From 8338e6625d20f9679f420d52b69dd057cd214bf8 Mon Sep 17 00:00:00 2001 From: kidiga Date: Sat, 9 Aug 2025 19:26:45 +0300 Subject: [PATCH 23/25] feat/ - Implement document details screen - Add `UiTimeOut` utility for consistent delay - Integrate document loading in `DocumentDetailsScreenViewModel` - Update `HomeScreenViewModel` and `SearchResultsScreenViewModel` to use `UiTimeOut` - Enhance `CreateScreenViewModel` with notifications for upload status - Add `baseline_check_24.xml` icon - Create `DateUtils` for date formatting - Modify `DocumentModel` to use current time for `publicationAt` - Update `documentModules` to include `DocumentDetailsScreenViewModel` - Refactor `DocumentDetails` screen UI and logic - Adjust `MainNav` to pass `DocumentDetailsScreenViewModel` --- .../com/kdbrian/sage/di/documentModules.kt | 5 ++ .../sage/domain/model/DocumentModel.kt | 2 +- .../main/java/com/kdbrian/sage/nav/MainNav.kt | 17 +++-- .../sage/ui/screens/DocumentDetails.kt | 71 +++++++++++++++---- .../sage/ui/screens/DocumentDetailsUiState.kt | 9 +++ .../sage/ui/state/CreateScreenViewModel.kt | 33 +++++++-- .../state/DocumentDetailsScreenViewModel.kt | 53 ++++++++++++++ .../sage/ui/state/HomeScreenViewModel.kt | 4 ++ .../ui/state/SearchResultsScreenViewModel.kt | 11 ++- .../com/kdbrian/sage/ui/util/UiTimeOut.kt | 5 ++ .../java/com/kdbrian/sage/util/DateUtils.kt | 30 ++++++++ .../main/res/drawable/baseline_check_24.xml | 5 ++ 12 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetailsUiState.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/state/DocumentDetailsScreenViewModel.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/util/UiTimeOut.kt create mode 100644 app/src/main/java/com/kdbrian/sage/util/DateUtils.kt create mode 100644 app/src/main/res/drawable/baseline_check_24.xml diff --git a/app/src/main/java/com/kdbrian/sage/di/documentModules.kt b/app/src/main/java/com/kdbrian/sage/di/documentModules.kt index 10c6c79..cfb4ee2 100644 --- a/app/src/main/java/com/kdbrian/sage/di/documentModules.kt +++ b/app/src/main/java/com/kdbrian/sage/di/documentModules.kt @@ -2,6 +2,8 @@ package com.kdbrian.sage.di import com.kdbrian.sage.data.remote.impl.DocumentRepoImpl import com.kdbrian.sage.domain.repo.DocumentRepo +import com.kdbrian.sage.ui.state.DocumentDetailsScreenViewModel +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val documentModules = module { @@ -13,4 +15,7 @@ val documentModules = module { ) } + viewModelOf(::DocumentDetailsScreenViewModel) + + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt index b577eb3..dec5ee2 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt @@ -10,7 +10,7 @@ import java.util.UUID data class DocumentModel( val documentId: String = UUID.randomUUID().toString(), val documentName: String = "", - val publicationAt: String = "", + val publicationAt: String = "${System.currentTimeMillis()}", val topics: List = emptyList(), val publisher: String = "",//not firebase uid but will be loaded into user account and offloaded val summary: String = "", diff --git a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt index a49b11c..8519882 100644 --- a/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -22,6 +22,7 @@ import com.kdbrian.sage.ui.screens.HomeScreen import com.kdbrian.sage.ui.screens.ProfileScreen import com.kdbrian.sage.ui.screens.SearchResults import com.kdbrian.sage.ui.screens.TopicDetails +import com.kdbrian.sage.ui.state.DocumentDetailsScreenViewModel import com.kdbrian.sage.ui.state.SearchResultsScreenViewModel import com.kdbrian.sage.ui.state.TopicDetailsViewModel import org.koin.compose.viewmodel.koinViewModel @@ -33,6 +34,7 @@ fun MainNav() { val navController = rememberNavController() val topicDetailsViewModel = koinViewModel() val searchResultsScreenViewModel = koinViewModel() + val documentDetailsScreenViewModel = koinViewModel() NavHost( @@ -80,10 +82,14 @@ fun MainNav() { composable { val detailsRoute = it.toRoute() + + LaunchedEffect(Unit) { + documentDetailsScreenViewModel.loadDocumentById(detailsRoute.docId) + } + DocumentDetails( navHostController = navController, - documentId = detailsRoute.docId - + documentDetailsScreenViewModel = documentDetailsScreenViewModel ) } @@ -107,13 +113,6 @@ fun MainNav() { ) } - composable { - val detailsRoute = it.toRoute() - DocumentDetails( - navHostController = navController, - documentId = detailsRoute.docId - ) - } } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt index c0c31ed..3ff9e55 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt @@ -2,6 +2,7 @@ package com.kdbrian.sage.ui.screens import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,7 +10,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Copyright @@ -25,7 +29,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -41,15 +48,26 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.kdbrian.sage.App import com.kdbrian.sage.R -import java.util.UUID +import com.kdbrian.sage.nav.SearchResultsRoute +import com.kdbrian.sage.ui.state.DocumentDetailsScreenViewModel +import com.kdbrian.sage.ui.state.SearchResultsUiState +import com.kdbrian.sage.ui.util.UiTimeOut +import com.kdbrian.sage.util.DateUtils.formatAsDate +import kotlinx.coroutines.delay +import org.koin.compose.viewmodel.koinViewModel +import timber.log.Timber @OptIn(ExperimentalMaterial3Api::class) @Composable fun DocumentDetails( + documentDetailsScreenViewModel: DocumentDetailsScreenViewModel = koinViewModel(), navHostController: NavHostController = rememberNavController(), - documentId: String = UUID.randomUUID().toString() ) { + val uiState by documentDetailsScreenViewModel.uiState.collectAsState() + val messages by documentDetailsScreenViewModel.messages.collectAsState("") + + var isFavorite by remember { mutableStateOf(false) } @@ -68,6 +86,24 @@ fun DocumentDetails( 18.dp ) +// val swapDelay = 900L + var index by remember { + mutableIntStateOf(0) + } + + LaunchedEffect(Unit) { + while (true) { + Timber.d("swapped index: $index") + + delay(UiTimeOut.timeOut) + if (index == (uiState.documentModel?.topics?.size ?: 0) - 1) { + index-- + } else { + index++ + } + } + } + Scaffold( topBar = { @@ -77,7 +113,14 @@ fun DocumentDetails( TopAppBar( title = { Column { - Text(text = LoremIpsum(2).values.joinToString()) + Text( + text = uiState.documentModel?.topics[index] ?: "", + modifier = Modifier.padding(6.dp).clickable { + navHostController.navigate( + SearchResultsRoute(query = "topic:${uiState.documentModel?.topics[index]}") + ) + } + ) } }, navigationIcon = { @@ -105,30 +148,34 @@ fun DocumentDetails( ) } }) { + val scrollState = rememberScrollState() Column( modifier = Modifier .padding(it) .padding(16.dp) + .verticalScroll(scrollState) ) { Text( - text = LoremIpsum(3).values.joinToString(), + text = uiState.documentModel?.documentName ?: "", style = MaterialTheme.typography.displayMedium ) Spacer(Modifier.padding(12.dp)) - Text( - text = LoremIpsum(24).values.joinToString(), - style = MaterialTheme.typography.bodyLarge + BasicText( + text = uiState.documentModel?.summary ?: "", + style = MaterialTheme.typography.bodyLarge, + maxLines = 12, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) Surface( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(24.dp), shadowElevation = 2.dp ) { @@ -159,13 +206,13 @@ fun DocumentDetails( } Text( - text = LoremIpsum(3).values.joinToString(), + text = "u#${uiState.documentModel?.publisher}", style = MaterialTheme.typography.bodyMedium ) Text( - text = LoremIpsum(3).values.joinToString(), + text = uiState.documentModel?.publicationAt?.toLong()?.formatAsDate() ?: "", style = MaterialTheme.typography.bodyMedium ) @@ -177,7 +224,7 @@ fun DocumentDetails( modifier = Modifier .fillMaxWidth() .padding(top = 12.dp), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(24.dp), shadowElevation = 5.dp ) { @@ -208,7 +255,7 @@ fun DocumentDetails( } Text( - text = LoremIpsum(18).values.joinToString(), + text = uiState.documentModel?.aiSummary ?: "Still building.....", style = MaterialTheme.typography.bodyMedium ) diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetailsUiState.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetailsUiState.kt new file mode 100644 index 0000000..4976ed5 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetailsUiState.kt @@ -0,0 +1,9 @@ +package com.kdbrian.sage.ui.screens + +import coil3.compose.AsyncImagePainter +import com.kdbrian.sage.domain.model.DocumentModel + +data class DocumentDetailsUiState( + val documentModel: DocumentModel? = null, + val isLoading: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt index 54412f7..b2031c3 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt @@ -17,7 +17,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import androidx.core.net.toUri +import com.kdbrian.sage.R +import com.kdbrian.sage.ui.util.UiTimeOut +import com.kdbrian.sage.util.NotificationData +import com.kdbrian.sage.util.NotificationUtils.showNotification import kotlinx.coroutines.delay +import java.util.Date +import kotlin.random.Random class CreateScreenViewModel( private val context: Context, @@ -31,8 +37,6 @@ class CreateScreenViewModel( private val messageChannel = Channel() val messages = messageChannel.receiveAsFlow() - private val MessageUpdateTimeout = 3500L - fun validate(): Boolean { @@ -75,6 +79,7 @@ class CreateScreenViewModel( } + @androidx.annotation.RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) fun uploadDocument() { viewModelScope.launch { _mutableState.value= _mutableState.value.copy(isUploading = true) @@ -93,16 +98,34 @@ class CreateScreenViewModel( documentModel = documentModel ) - result.onSuccess { + result.onSuccess { messageChannel.send("document uploaded successfully.") - delay(MessageUpdateTimeout) + context.showNotification( + data = NotificationData( + channelId = context.getString(R.string.default_notification_channel), + title = "Upload task", + message = "${documentModel.documentName} uploaded successfully at ${Date(documentModel.publicationAt)}", + smallIcon = R.drawable.baseline_check_24 + ), + notificationId = Random.nextInt(1,9) + ) + delay(UiTimeOut.timeOut) _mutableState.value = CreateScreenUiState() } result.onFailure { messageChannel.send("document uploaded failed ${it.message}") - delay(MessageUpdateTimeout) + delay(UiTimeOut.timeOut) _mutableState.value = _mutableState.value.copy(isUploading = false) + context.showNotification( + data = NotificationData( + channelId = context.getString(R.string.default_notification_channel), + title = "Upload task", + message = "${documentModel.documentName} upload failed at ${Date(documentModel.publicationAt)}", + smallIcon = R.drawable.outline_brightness_alert_24 + ), + notificationId = Random.nextInt(1,9) + ) } }else diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/DocumentDetailsScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/DocumentDetailsScreenViewModel.kt new file mode 100644 index 0000000..dff34f5 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/DocumentDetailsScreenViewModel.kt @@ -0,0 +1,53 @@ +package com.kdbrian.sage.ui.state + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kdbrian.sage.domain.repo.DocumentRepo +import com.kdbrian.sage.ui.screens.DocumentDetailsUiState +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import timber.log.Timber + + +class DocumentDetailsScreenViewModel( + private val documentRepo: DocumentRepo +) : ViewModel() { + + + private val _mutableState = MutableStateFlow(DocumentDetailsUiState()) + val uiState = _mutableState.asStateFlow() + + private val messageChannel = Channel() + val messages = messageChannel.receiveAsFlow() + + + fun loadDocumentById(documentId: String) { + viewModelScope.launch { + _mutableState.value = _mutableState.value.copy(isLoading = true) + + val simplerSearch = documentRepo.simplerSearch( + row = "documentId", + rowValue = documentId, + filter = "equals" + ) + + simplerSearch.fold( + onSuccess = { + _mutableState.value = _mutableState.value.copy( + documentModel = it.first() + ) + }, + onFailure = { + Timber.d("failed: ${it.message}") + } + ) + } + + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt index f3e021f..ef3b7c5 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kdbrian.sage.domain.repo.TopicRepo +import com.kdbrian.sage.ui.util.UiTimeOut +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -34,6 +36,7 @@ class HomeScreenViewModel( allTopics.onSuccess { Timber.d("done ${it.size}") + delay(UiTimeOut.timeOut) _mutableState.value = _mutableState.value.copy( topics = it, isLoading = false, @@ -43,6 +46,7 @@ class HomeScreenViewModel( allTopics.onFailure { Timber.d("Failed ${it.message}") + delay(UiTimeOut.timeOut) _mutableState.value = _mutableState.value.copy( message = it.message ?: "Something went wrong : ${it.message}", isLoading = false diff --git a/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt b/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt index 0e45efe..91b0bf3 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt @@ -3,6 +3,7 @@ package com.kdbrian.sage.ui.state import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kdbrian.sage.domain.repo.DocumentRepo +import com.kdbrian.sage.ui.util.UiTimeOut import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -21,8 +22,6 @@ class SearchResultsScreenViewModel( private val messageChannel = Channel() val messages = messageChannel.receiveAsFlow() - private val UITimeout = 1200L - fun loadSearchResults(query: String) { viewModelScope.launch { @@ -48,7 +47,7 @@ class SearchResultsScreenViewModel( messageChannel.send("${it.size} documents loaded successfully.") } - delay(UITimeout) + delay(UiTimeOut.timeOut) _mutableState.value = _mutableState.value.copy( @@ -59,7 +58,7 @@ class SearchResultsScreenViewModel( onFailure = { Timber.d("failed: ${it.message}") messageChannel.send(it.message.toString()) - delay(UITimeout) + delay(UiTimeOut.timeOut) _mutableState.value = _mutableState.value.copy( isLoading = false, ) @@ -77,7 +76,7 @@ class SearchResultsScreenViewModel( } else { messageChannel.send("${it.size} documents loaded successfully.") } - delay(UITimeout) + delay(UiTimeOut.timeOut) _mutableState.value = _mutableState.value.copy( isLoading = false, documentsFromUploaded = it @@ -86,7 +85,7 @@ class SearchResultsScreenViewModel( onFailure = { Timber.tag("documentsByQuery").d("failed: ${it.message}") messageChannel.send(it.message.toString()) - delay(UITimeout) + delay(UiTimeOut.timeOut) _mutableState.value = _mutableState.value.copy( isLoading = false, ) diff --git a/app/src/main/java/com/kdbrian/sage/ui/util/UiTimeOut.kt b/app/src/main/java/com/kdbrian/sage/ui/util/UiTimeOut.kt new file mode 100644 index 0000000..fa746ef --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/util/UiTimeOut.kt @@ -0,0 +1,5 @@ +package com.kdbrian.sage.ui.util + +object UiTimeOut { + val timeOut = 1200L +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/util/DateUtils.kt b/app/src/main/java/com/kdbrian/sage/util/DateUtils.kt new file mode 100644 index 0000000..904ed81 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/DateUtils.kt @@ -0,0 +1,30 @@ +package com.kdbrian.sage.util + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object DateUtils { + + + fun Long.formatAsDate(pattern: String = MONTH_DAY): String { + val date = Date(this) + val formatter = SimpleDateFormat(pattern, Locale.getDefault()) + return formatter.format(date) + } + + + const val DEFAULT = "yyyy-MM-dd HH:mm:ss" + const val DATE_ONLY = "yyyy-MM-dd" + const val TIME_ONLY = "HH:mm:ss" + const val READABLE_DATE = "dd MMM yyyy" + const val READABLE_DATE_TIME = "dd MMM yyyy, HH:mm" + const val ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" + const val US_DATE = "MM/dd/yyyy" + const val EU_DATE = "dd/MM/yyyy" + const val SHORT_DATE = "dd/MM/yy" + const val MONTH_DAY = "MMM dd" + const val FULL = "EEEE, dd MMMM yyyy HH:mm:ss" + + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_check_24.xml b/app/src/main/res/drawable/baseline_check_24.xml new file mode 100644 index 0000000..814572c --- /dev/null +++ b/app/src/main/res/drawable/baseline_check_24.xml @@ -0,0 +1,5 @@ + + + + + From cfff14d5c2ef03482dd6fa63ff112707dffb2cff Mon Sep 17 00:00:00 2001 From: kidiga Date: Sat, 9 Aug 2025 19:35:13 +0300 Subject: [PATCH 24/25] feat/ - Implement document details screen - Add `UiTimeOut` utility for consistent delay - Integrate document loading in `DocumentDetailsScreenViewModel` - Update `HomeScreenViewModel` and `SearchResultsScreenViewModel` to use `UiTimeOut` - Enhance `CreateScreenViewModel` with notifications for upload status - Add `baseline_check_24.xml` icon - Create `DateUtils` for date formatting - Modify `DocumentModel` to use current time for `publicationAt` - Update `documentModules` to include `DocumentDetailsScreenViewModel` - Refactor `DocumentDetails` screen UI and logic - Adjust `MainNav` to pass `DocumentDetailsScreenViewModel` --- app/build.gradle.kts | 8 ++++++++ .../sage/data/local/model/InAppActivity.kt | 13 +++++++++++++ .../sage/data/local/model/dao/InAppActivityDao.kt | 15 +++++++++++++++ build.gradle.kts | 3 ++- gradle/libs.versions.toml | 2 ++ 5 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt create mode 100644 app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0ff51f..c3adc87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,12 +12,14 @@ fun getSecret(key: String): String? { } plugins { + alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") kotlin("plugin.serialization") version "2.2.0" alias(libs.plugins.google.gms.google.services) + id("com.google.devtools.ksp") } @@ -133,5 +135,11 @@ dependencies { //accompanist permissions implementation(libs.accompanist.permissions) + //room + val room_version = "2.7.2" + implementation("androidx.room:room-runtime:$room_version") + ksp("androidx.room:room-compiler:$room_version") + implementation("androidx.room:room-ktx:${room_version}") + } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt b/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt new file mode 100644 index 0000000..a010d73 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt @@ -0,0 +1,13 @@ +package com.kdbrian.sage.data.local.model + +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class InAppActivity( + val id: String = UUID.randomUUID().toString(), + val timestamp: Long = System.currentTimeMillis(), + val name: String = "", + val route: String? = "", +) diff --git a/app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt b/app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt new file mode 100644 index 0000000..dc31af2 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt @@ -0,0 +1,15 @@ +package com.kdbrian.sage.data.local.model.dao + +import com.kdbrian.sage.data.local.model.InAppActivity +import kotlinx.coroutines.flow.Flow + +interface InAppActivityDao { + + fun logActivity(inAppActivity: InAppActivity) + + fun logs(): Flow> + + fun loadLogById(id: String): InAppActivity? + + fun clearLogs() +} diff --git a/build.gradle.kts b/build.gradle.kts index a13fed3..eda74c4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ buildscript { dependencies { - classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") + classpath(libs.secrets.gradle.plugin) } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { @@ -8,4 +8,5 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.google.gms.google.services) apply false + id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f36d294..f8d5ffa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ composeBom = "2025.07.00" junitJupiter = "5.8.1" materialIconsExtended = "1.7.8" navigationCompose = "2.9.3" +secretsGradlePlugin = "2.0.1" timber = "5.0.1" googleGmsGoogleServices = "4.4.3" firebaseAnalytics = "23.0.0" @@ -49,6 +50,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junitJupiter" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koinBom" } koin-core = { module = "io.insert-koin:koin-core" } From 74d55949590bf54ba5ab44f773478075782fc1aa Mon Sep 17 00:00:00 2001 From: kidiga Date: Sun, 10 Aug 2025 18:48:28 +0300 Subject: [PATCH 25/25] feat/ - Implement Realm for local data storage - Add `InAppActivity` Realm object - Create `InAppActivityDao` and `InAppActivityDaoImpl` for activity logging - Integrate Realm into dependency injection with `realmModules` - Update `TopicDetails` screen to display documents and handle loading state - Modify `DocumentDetails` screen to use current time for publication date if missing - Add `MyActivity` screen (initial commit) - Update `DocumentModel` collection names to use `val` - Make `InAppActivityDao` methods suspend functions --- app/build.gradle.kts | 12 +++-- app/src/main/java/com/kdbrian/sage/Sage.kt | 2 + .../sage/data/local/model/InAppActivity.kt | 10 ++-- .../data/local/model/dao/InAppActivityDao.kt | 8 +-- .../local/model/impl/InAppActivityDaoImpl.kt | 51 +++++++++++++++++++ .../java/com/kdbrian/sage/di/realmModules.kt | 30 +++++++++++ .../sage/domain/model/DocumentModel.kt | 8 +-- .../sage/ui/screens/DocumentDetails.kt | 18 +++---- .../com/kdbrian/sage/ui/screens/MyActivity.kt | 32 ++++++++++++ .../kdbrian/sage/ui/screens/TopicDetails.kt | 30 ++++++++--- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + 12 files changed, 168 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/kdbrian/sage/data/local/model/impl/InAppActivityDaoImpl.kt create mode 100644 app/src/main/java/com/kdbrian/sage/di/realmModules.kt create mode 100644 app/src/main/java/com/kdbrian/sage/ui/screens/MyActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3adc87..32d1a35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,7 +20,7 @@ plugins { kotlin("plugin.serialization") version "2.2.0" alias(libs.plugins.google.gms.google.services) id("com.google.devtools.ksp") - + id ("io.realm.kotlin") } android { @@ -136,10 +136,12 @@ dependencies { implementation(libs.accompanist.permissions) //room - val room_version = "2.7.2" - implementation("androidx.room:room-runtime:$room_version") - ksp("androidx.room:room-compiler:$room_version") - implementation("androidx.room:room-ktx:${room_version}") +// val room_version = "2.7.2" +// implementation("androidx.room:room-runtime:$room_version") +// ksp("androidx.room:room-compiler:$room_version") +// implementation("androidx.room:room-ktx:${room_version}") + //realm + implementation(libs.library.base) } \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/Sage.kt b/app/src/main/java/com/kdbrian/sage/Sage.kt index 2bac18b..7bc79de 100644 --- a/app/src/main/java/com/kdbrian/sage/Sage.kt +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -7,6 +7,7 @@ import com.kdbrian.sage.di.createDocumentModules import com.kdbrian.sage.di.documentModules import com.kdbrian.sage.di.firebaseModules import com.kdbrian.sage.di.homeModules +import com.kdbrian.sage.di.realmModules import com.kdbrian.sage.di.searchModules import com.kdbrian.sage.di.topicModules import org.koin.android.ext.koin.androidContext @@ -34,6 +35,7 @@ class Sage : Application() { firebaseModules, homeModules, searchModules, + realmModules, topicModules, ) } diff --git a/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt b/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt index a010d73..da6996e 100644 --- a/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt +++ b/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt @@ -1,13 +1,15 @@ package com.kdbrian.sage.data.local.model import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.PrimaryKey import kotlinx.serialization.Serializable import java.util.UUID -@Serializable -data class InAppActivity( - val id: String = UUID.randomUUID().toString(), +class InAppActivity( + @PrimaryKey + val _id: org.mongodb.kbson.ObjectId = org.mongodb.kbson.ObjectId(), val timestamp: Long = System.currentTimeMillis(), val name: String = "", val route: String? = "", -) +) : RealmObject diff --git a/app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt b/app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt index dc31af2..4af50e2 100644 --- a/app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt +++ b/app/src/main/java/com/kdbrian/sage/data/local/model/dao/InAppActivityDao.kt @@ -5,11 +5,11 @@ import kotlinx.coroutines.flow.Flow interface InAppActivityDao { - fun logActivity(inAppActivity: InAppActivity) + suspend fun logActivity(inAppActivity: InAppActivity) - fun logs(): Flow> + suspend fun logs(): Flow - fun loadLogById(id: String): InAppActivity? + suspend fun loadLogById(id: String): InAppActivity? - fun clearLogs() + suspend fun clearLogs() } diff --git a/app/src/main/java/com/kdbrian/sage/data/local/model/impl/InAppActivityDaoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/local/model/impl/InAppActivityDaoImpl.kt new file mode 100644 index 0000000..9e61049 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/local/model/impl/InAppActivityDaoImpl.kt @@ -0,0 +1,51 @@ +package com.kdbrian.sage.data.local.model.impl + +import com.kdbrian.sage.data.local.model.InAppActivity +import com.kdbrian.sage.data.local.model.dao.InAppActivityDao +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.isManaged +import io.realm.kotlin.query.RealmElementQuery +import io.realm.kotlin.query.RealmQuery +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.lang.Exception + +class InAppActivityDaoImpl( + private val realm: Realm +) : InAppActivityDao { + + override suspend fun logActivity(inAppActivity: InAppActivity) { + withContext(Dispatchers.Default) { + try { + realm.write { + copyToRealm(inAppActivity) + } + } catch (e: Exception) { + Timber.tag("logActivity").d(e.message.toString()) + } + } + } + + override suspend fun logs(): Flow = withContext(Dispatchers.Default) { + val asFlow = realm.query(clazz = InAppActivity::class).find() + .toList() + .asFlow() + asFlow + } + + override suspend fun loadLogById(id: String): InAppActivity? = + withContext(Dispatchers.Default) { + realm.query( + clazz = InAppActivity::class, + query = "_id = $0", + id + ).find().firstOrNull() + } + + override suspend fun clearLogs() = withContext(Dispatchers.Default) { + realm.write { delete(InAppActivity::class) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/di/realmModules.kt b/app/src/main/java/com/kdbrian/sage/di/realmModules.kt new file mode 100644 index 0000000..9e53a29 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/realmModules.kt @@ -0,0 +1,30 @@ +package com.kdbrian.sage.di + +import com.kdbrian.sage.data.local.model.InAppActivity +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import org.koin.dsl.module + +val realmModules = module { + + single{ + val realmConfiguration = RealmConfiguration.Builder( + schema = setOf( + InAppActivity::class + ) + ) + realmConfiguration.apply { + name("sage-realm.realm") + } + realmConfiguration.build() + } + + + single { + Realm.open( + get() + ) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt index dec5ee2..3cd0c39 100644 --- a/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt +++ b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt @@ -1,8 +1,6 @@ package com.kdbrian.sage.domain.model -import com.google.firebase.events.Publisher import com.kdbrian.sage.BuildConfig -import kotlinx.serialization.MetaSerializable import kotlinx.serialization.Serializable import java.util.UUID @@ -20,10 +18,8 @@ data class DocumentModel( val uri: String = "", ) { companion object { - const val defaultCollectionName = - "${BuildConfig.APPLICATION_ID}/documents_meta/default" //our inventory - const val generatedCollectionName = - "${BuildConfig.APPLICATION_ID}/documents_meta/uploaded"//uploaded by user + val defaultCollectionName = "${BuildConfig.APPLICATION_ID}/documents_meta/default" //our inventory + val generatedCollectionName = "${BuildConfig.APPLICATION_ID}/documents_meta/uploaded"//uploaded by user } } diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt index 3ff9e55..ee7954f 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt @@ -86,15 +86,12 @@ fun DocumentDetails( 18.dp ) -// val swapDelay = 900L var index by remember { mutableIntStateOf(0) } LaunchedEffect(Unit) { while (true) { - Timber.d("swapped index: $index") - delay(UiTimeOut.timeOut) if (index == (uiState.documentModel?.topics?.size ?: 0) - 1) { index-- @@ -115,11 +112,13 @@ fun DocumentDetails( Column { Text( text = uiState.documentModel?.topics[index] ?: "", - modifier = Modifier.padding(6.dp).clickable { - navHostController.navigate( - SearchResultsRoute(query = "topic:${uiState.documentModel?.topics[index]}") - ) - } + modifier = Modifier + .padding(6.dp) + .clickable { + navHostController.navigate( + SearchResultsRoute(query = "topic:${uiState.documentModel?.topics[index]}") + ) + } ) } }, @@ -212,7 +211,8 @@ fun DocumentDetails( Text( - text = uiState.documentModel?.publicationAt?.toLong()?.formatAsDate() ?: "", + text = (uiState.documentModel?.publicationAt?.toLong() + ?: System.currentTimeMillis()).formatAsDate(), style = MaterialTheme.typography.bodyMedium ) diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/MyActivity.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/MyActivity.kt new file mode 100644 index 0000000..b3fb6bd --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/MyActivity.kt @@ -0,0 +1,32 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.kdbrian.sage.App + +@Composable +fun MyActivity() { + + Scaffold { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + + } + } +} + +@Preview +@Composable +private fun MyActivityPrev() { + App { + MyActivity() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt index bdb00ed..311b471 100644 --- a/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Favorite @@ -25,6 +26,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -146,16 +148,27 @@ fun TopicDetails( .padding(it) .padding(12.dp) ) { - items(5) { - ListItemPreview( - onClick = { - navController.navigate( - DocumentDetailsRoute( - UUID.randomUUID().toString().split("-").first() + if (uiState.isLoading) { + items(3) { + ListItemPreview() + } + } else { +// uiState.topicDocuments?.let { documentModels -> + itemsIndexed(uiState.topicDocuments) { index, model -> + key(index) { + model?.let { m -> + ListItemPreview( + title = m.documentName, + onClick = { + navController.navigate( + DocumentDetailsRoute(m.documentId) + ) + } ) - ) + } } - ) + } +// } } item { @@ -181,6 +194,7 @@ fun TopicDetails( } } } + } } diff --git a/build.gradle.kts b/build.gradle.kts index eda74c4..0042bc9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,4 +9,5 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.google.gms.google.services) apply false id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false + id ("io.realm.kotlin") version "3.0.0" apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f8d5ffa..07eefc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" kotlinxSerializationJson = "1.9.0" +libraryBase = "3.0.0" lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" composeBom = "2025.07.00" @@ -50,6 +51,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junitJupiter" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +library-base = { module = "io.realm.kotlin:library-base", version.ref = "libraryBase" } secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koinBom" }