diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c63a36..32d1a35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,18 +12,23 @@ 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") + id ("io.realm.kotlin") } 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 @@ -87,6 +92,9 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) 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) @@ -108,6 +116,32 @@ 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) + + //extended icons + implementation(libs.androidx.material.icons.extended) + + //serialization + implementation(libs.kotlinx.serialization.json) + + //datastore prefs + implementation(libs.androidx.datastore.preferences) + + //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}") + //realm + implementation(libs.library.base) } \ 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2607503..5aad687 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,8 +3,10 @@ xmlns:tools="http://schemas.android.com/tools"> + + + 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 new file mode 100644 index 0000000..73249ce --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/MainActivity.kt @@ -0,0 +1,148 @@ +package com.kdbrian.sage + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.core.view.WindowCompat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +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 timber.log.Timber + +class MainActivity : ComponentActivity() { + + @OptIn(ExperimentalPermissionsApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() + + //create notification channel for android 14 and up + if (Build.VERSION.SDK_INT >= 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 { + + /* 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) + 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) + ) + + coroutineScope.launch { + appDataStore.updateFirstTime(false); + } + + } + } + }*/ + + + MainNav() + } + } + + } + + private 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.defaultCollectionName) + + 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) + } + } + + +} + + +@Composable +fun App(content: @Composable () -> Unit) { + SageTheme { + content() + } +} \ 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 new file mode 100644 index 0000000..7bc79de --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/Sage.kt @@ -0,0 +1,43 @@ +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 +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 +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin +import timber.log.Timber + +class Sage : Application() { + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) + Timber.plant(Timber.DebugTree()) + + startKoin { + + androidLogger() + androidContext(this@Sage) + + modules( + analyticsModule, + coreModules, + createDocumentModules, + documentModules, + firebaseModules, + homeModules, + searchModules, + realmModules, + topicModules, + ) + } + } +} \ 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..da6996e --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/local/model/InAppActivity.kt @@ -0,0 +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 + +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 new file mode 100644 index 0000000..4af50e2 --- /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 { + + suspend fun logActivity(inAppActivity: InAppActivity) + + suspend fun logs(): Flow + + suspend fun loadLogById(id: String): InAppActivity? + + 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/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/data/remote/impl/DocumentRepoImpl.kt b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt new file mode 100644 index 0000000..28d690c --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/DocumentRepoImpl.kt @@ -0,0 +1,212 @@ +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 +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 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, + 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 + ) { + 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 new file mode 100644 index 0000000..aa7ffe1 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/data/remote/impl/TopicRepoImpl.kt @@ -0,0 +1,82 @@ +package com.kdbrian.sage.data.remote.impl + +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 loadDefaultTopics(): Result> = + withContext(Dispatchers.Default) { + try { + val documentSnapshots = + firestore.collection(TopicItem.defaultCollectionName).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 loadTopicsByQuery(query: String): Result> = + withContext(Dispatchers.Default) { + try { + val documentSnapshots = firestore + .collection(TopicItem.defaultCollectionName) + .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/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/coreModules.kt b/app/src/main/java/com/kdbrian/sage/di/coreModules.kt new file mode 100644 index 0000000..d4e3c1c --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/coreModules.kt @@ -0,0 +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/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 new file mode 100644 index 0000000..cfb4ee2 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/documentModules.kt @@ -0,0 +1,21 @@ +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 { + + single { + DocumentRepoImpl( + firebaseFirestore = get(), + firebaseStorage = get() + ) + } + + viewModelOf(::DocumentDetailsScreenViewModel) + + +} \ 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..de67563 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/firebaseModules.kt @@ -0,0 +1,24 @@ +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 { + + single { + FirebaseAnalytics.getInstance(get()) + } + + + single { + FirebaseFirestore.getInstance() + } + + single { + FirebaseStorage.getInstance() + } + +} \ 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/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/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/di/topicModules.kt b/app/src/main/java/com/kdbrian/sage/di/topicModules.kt new file mode 100644 index 0000000..560be90 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/di/topicModules.kt @@ -0,0 +1,20 @@ +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 { + + single { + TopicRepoImpl( + firestore = get() + ) + } + + 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..3cd0c39 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/model/DocumentModel.kt @@ -0,0 +1,25 @@ +package com.kdbrian.sage.domain.model + +import com.kdbrian.sage.BuildConfig +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class DocumentModel( + val documentId: String = UUID.randomUUID().toString(), + val documentName: 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 = "", + val isAiSummarizable: String = "", + var fileInfo: String = "", + val aiSummary: String = "", + val uri: String = "", +) { + companion object { + 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/domain/model/TopicItem.kt b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt new file mode 100644 index 0000000..bf7588d --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/model/TopicItem.kt @@ -0,0 +1,16 @@ +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 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/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/domain/repo/DocumentRepo.kt b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt new file mode 100644 index 0000000..552951a --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/DocumentRepo.kt @@ -0,0 +1,25 @@ +package com.kdbrian.sage.domain.repo + +import android.net.Uri +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 + + 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 new file mode 100644 index 0000000..ef4e0a3 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/domain/repo/TopicRepo.kt @@ -0,0 +1,10 @@ +package com.kdbrian.sage.domain.repo + +import com.kdbrian.sage.domain.model.TopicItem + +interface TopicRepo { + + 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 new file mode 100644 index 0000000..8519882 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/nav/MainNav.kt @@ -0,0 +1,120 @@ +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.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 +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.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 +import timber.log.Timber + +@Composable +fun MainNav() { + + val navController = rememberNavController() + val topicDetailsViewModel = koinViewModel() + val searchResultsScreenViewModel = koinViewModel() + val documentDetailsScreenViewModel = koinViewModel() + + + NavHost( + modifier = Modifier + .fillMaxSize(), + navController = navController, + startDestination = HomeScreenRoute + ) { + + composable { + HomeScreen( + onSearch = { + navController.navigate(SearchResultsRoute(it)) + }, + onOpenProfile = { + navController.navigate(ProfileRoute) + }, + onTopicExpand = { + navController.navigate(TopicDetailsRoute(it)) + }, + onOpenFYP = { + } + ) + } + + composable { + val detailsRoute = it.toRoute() + + LaunchedEffect(Unit) { + topicDetailsViewModel.loadTopicDetails(detailsRoute.topicId) + } + + TopicDetails( + topicDetailsViewModel = topicDetailsViewModel, + navController = navController, + onClose = { navController.popBackStack() } + ) + } + + composable { + ProfileScreen( + navHostController = navController + ) + } + + composable { + val detailsRoute = it.toRoute() + + LaunchedEffect(Unit) { + documentDetailsScreenViewModel.loadDocumentById(detailsRoute.docId) + } + + DocumentDetails( + navHostController = navController, + documentDetailsScreenViewModel = documentDetailsScreenViewModel + ) + } + + composable { + 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 + ) + } + + + } + + +} \ 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..5118231 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/nav/Route.kt @@ -0,0 +1,35 @@ +package com.kdbrian.sage.nav + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Route + + +@Serializable +data object GetStartedRoute : Route() + + +@Serializable +data object HomeScreenRoute : Route() + + +@Serializable +data object CreateScreenRoute : Route() + + +@Serializable +data class TopicDetailsRoute(val topicId : String) : Route() + + +@Serializable +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/CategoryItem.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt new file mode 100644 index 0000000..bf666d2 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CategoryItem.kt @@ -0,0 +1,81 @@ +package com.kdbrian.sage.ui.composables + +import androidx.compose.foundation.layout.Arrangement +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.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.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.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 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(), + onClick: () -> Unit = {}, + shape: Shape = RoundedCornerShape(24.dp) +) { + + Surface( + modifier = modifier + .requiredWidthIn(min = 100.dp, max = 150.dp) + .requiredHeightIn(min = 30.dp, max = 35.dp), + shape = shape, + color = color, + contentColor = contentColor, + onClick = onClick, + shadowElevation = 2.dp + ) { + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + icon?.invoke() + + BasicText( + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight(300), + textAlign = TextAlign.Center + ), + autoSize = TextAutoSize.StepBased( + minFontSize = 14.sp, + maxFontSize = 16.sp, + stepSize = 1.sp + ), + text = text, + modifier = Modifier.padding(4.dp) + ) + } + + } +} + +@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 new file mode 100644 index 0000000..5eaf3a5 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/CircularProfileAvatar.kt @@ -0,0 +1,48 @@ +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, + size: Int = 50, + imageUri: String? = null, + onImageClick: () -> Unit = {} +) { + + Surface( + onClick = onImageClick, + shadowElevation = 3.dp, + modifier = modifier + .requiredSize((size).dp), + shape = CircleShape + ) { + AsyncImage( + model = imageUri, + placeholder = painterResource(R.drawable.current_premium), + error = 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/ListItemPreview.kt b/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt new file mode 100644 index 0000000..ee41c0c --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/ListItemPreview.kt @@ -0,0 +1,111 @@ +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.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 +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 +import androidx.compose.ui.unit.dp +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 = {} +) { + + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .padding(4.dp), + shape = RoundedCornerShape(16.dp), + ) { + Row( + modifier = Modifier.padding(6.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, + + ) { + + BasicText( + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = Modifier.widthIn(max = 220.dp), + text = title, + style = MaterialTheme.typography.titleMedium + ) + + BasicText( + modifier = Modifier.widthIn(max = 180.dp).padding(4.dp), + text = subtitle, + maxLines = 1, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Light + ) + ) + } + + } + + + } + +} + +@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 new file mode 100644 index 0000000..b4bbd37 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RoundedInputField.kt @@ -0,0 +1,80 @@ +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.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.graphics.Shape +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, + singleLine: Boolean = true, + enabled: Boolean = true, + 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, + colors: TextFieldColors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.LightGray, + unfocusedPlaceholderColor = Color.White, + focusedPlaceholderColor = Color.LightGray, + 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.Black, + ), + supportText : @Composable (() -> Unit)? = null +) { + + TextField( + supportingText = supportText, + enabled = enabled, + value = fieldState.text.toString(), + onValueChange = { + onValueChange?.invoke(it) ?: fieldState.setTextAndPlaceCursorAtEnd(it) + }, + modifier = modifier, + singleLine = singleLine, + minLines = if (singleLine) 1 else 3, + shape = shape, + 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..89aa6f3 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/RowButton.kt @@ -0,0 +1,64 @@ +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.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.fillMaxWidth(), + 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)? =null, + onClick: () -> Unit = {} +) { + Surface( + modifier = modifier.padding(4.dp), + shape = shape, + onClick = onClick, + color = color, + shadowElevation = 3.dp, + contentColor = contentColor + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + leadingIcon?.invoke() + trailingIcon?.invoke() + + } + + } +} + +@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..e356786 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/composables/TopicCard.kt @@ -0,0 +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, + text: AnnotatedString = buildAnnotatedString { + append(LoremIpsum(2).values.joinToString()) + }, + onClick: () -> Unit = {} +) { + + Surface( + modifier = modifier.padding(4.dp), + shadowElevation = 2.dp, + onClick = onClick, + shape = RoundedCornerShape(50) + ) { + Box( + modifier = modifier + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + textAlign = TextAlign.Center + ) + + } + } + +} + +@Preview( + showBackground = true, +) +@Composable +private fun TopicCardPrev() { + SageTheme { + 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/CreateScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt new file mode 100644 index 0000000..503f56f --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/CreateScreen.kt @@ -0,0 +1,475 @@ +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 +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.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.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.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.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.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 com.kdbrian.sage.util.Utils.displayToast +import org.koin.compose.viewmodel.koinViewModel +import timber.log.Timber + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateScreen( + createScreenViewModel: CreateScreenViewModel = koinViewModel() +) { + + val uiState by createScreenViewModel.uiState.collectAsState() + val context = LocalContext.current + val bottomSheetState = rememberModalBottomSheetState() + var isAddingTopic by remember { + mutableStateOf(false) + } + LaunchedEffect(isAddingTopic) { + if (!isAddingTopic) { + bottomSheetState.hide() + } else { + bottomSheetState.show() + } + } + 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 (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 + + + Box( + modifier = Modifier + .fillMaxSize() + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + Column( + 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") + } + + + + withStyle( + SpanStyle( + fontSize = MaterialTheme.typography.displayMedium.fontSize, + fontWeight = FontWeight.Bold, + baselineShift = BaselineShift.Superscript, + textDecoration = TextDecoration.Underline + ) + ) { + append("reate\n") + } + + + 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 + ) + ) { + 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.") + } + }, + 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", + "application/msword", + "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( + 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( + 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 + ) { + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + + Text(text = "Topics", style = MaterialTheme.typography.titleLarge) + + 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( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(4.dp), + rows = GridCells.Fixed(2), + ) { + Timber.d("uiState.topics.size : ${uiState.topics.size}") + itemsIndexed(uiState.topics.toList()) { index, topic -> + key(index) { + CategoryItem( + 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(uiState.isUploading, modifier = Modifier.align(Alignment.BottomCenter)) { + Surface( + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 24.dp + ) + ) { + 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 + ) { + 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 = { + createScreenViewModel.addTopic(topicName.text.trim().toString()) + topicName.clearText() + }) + } + } + } + +} + +@Preview +@Composable +private fun CreateScreenPrev() { + SageTheme { + CreateScreen() + } +} \ No newline at end of file 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..ee7954f --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/DocumentDetails.kt @@ -0,0 +1,297 @@ +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 +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 +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.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 +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 androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.kdbrian.sage.App +import com.kdbrian.sage.R +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(), +) { + + val uiState by documentDetailsScreenViewModel.uiState.collectAsState() + val messages by documentDetailsScreenViewModel.messages.collectAsState("") + + + 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 + ) + + var index by remember { + mutableIntStateOf(0) + } + + LaunchedEffect(Unit) { + while (true) { + delay(UiTimeOut.timeOut) + if (index == (uiState.documentModel?.topics?.size ?: 0) - 1) { + index-- + } else { + index++ + } + } + } + + + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp + ) { + TopAppBar( + title = { + Column { + Text( + text = uiState.documentModel?.topics[index] ?: "", + modifier = Modifier + .padding(6.dp) + .clickable { + navHostController.navigate( + SearchResultsRoute(query = "topic:${uiState.documentModel?.topics[index]}") + ) + } + ) + } + }, + navigationIcon = { + IconButton(onClick = { + navHostController.popBackStack() + }) { + 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) + ) + } + }, + ) + } + }) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(it) + .padding(16.dp) + .verticalScroll(scrollState) + ) { + + + Text( + text = uiState.documentModel?.documentName ?: "", + style = MaterialTheme.typography.displayMedium + ) + + Spacer(Modifier.padding(12.dp)) + + 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(24.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 = "u#${uiState.documentModel?.publisher}", + style = MaterialTheme.typography.bodyMedium + ) + + + Text( + text = (uiState.documentModel?.publicationAt?.toLong() + ?: System.currentTimeMillis()).formatAsDate(), + style = MaterialTheme.typography.bodyMedium + ) + + } + } + + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + shape = RoundedCornerShape(24.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 = uiState.documentModel?.aiSummary ?: "Still building.....", + 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/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/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/HomeScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..cad57c9 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/HomeScreen.kt @@ -0,0 +1,96 @@ +package com.kdbrian.sage.ui.screens + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +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 +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.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(), + onSearch: (String) -> Unit = {}, + onOpenProfile: () -> Unit, + onTopicExpand: (String) -> Unit, + onOpenFYP: () -> Unit = {} +) { + + val homeScreenViewModel = koinViewModel() + val uiState by homeScreenViewModel.uiState.collectAsState() + 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) { + popUpTo(HomeScreenRoute) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon(imageVector = item.icon, contentDescription = null) + }, + label = { + Text(text = item.label) + } + ) + } + } + } + ) { + NavHost( + modifier = Modifier.padding(it), + navController = navHostController, + startDestination = HomeScreenRoute + ) { + composable { + LandingPage( + onSearch = onSearch, + uiState = uiState, + onOpenProfile = onOpenProfile, + onOpenTopic = onTopicExpand, + openFYP = onOpenFYP + ) + } + composable { + CreateScreen() + } + } + + + } + +} + +@Preview +@Composable +private fun HomeScreenPrev() { + SageTheme { +// 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..f55559f --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/LandingPage.kt @@ -0,0 +1,407 @@ +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 +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.layout.width +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.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 +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.platform.LocalFocusManager +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, + onSearch: (String) -> Unit = {}, + onOpenProfile: () -> Unit = {}, + onOpenTopic: (String) -> Unit = {}, + openFYP: () -> Unit = {} +) { + + val appDataStore = koinInject() + + var isExpanded by remember { + mutableStateOf(false) + } + LaunchedEffect(Unit) { + isExpanded = appDataStore.firstTime.first() + } + + val focusManager = LocalFocusManager.current + 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 -> + + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(pd) + .fillMaxSize() + ) { + + Column { + + Text( + text = "Documents For You", + modifier = Modifier.padding(top = 12.dp, start = 12.dp), + style = MaterialTheme.typography.titleLarge + .copy(fontWeight = FontWeight.Bold) + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .padding(horizontal = 12.dp) + .requiredHeightIn( + max = 170.dp, + min = 150.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) + ) + }, + onClick = openFYP + ) + + TextButton(onClick = {}) { + Text(text = "Shared Documents") + } + } + + + } + + + } + } + + + RoundedInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp), + fieldState = uiState.searchQuery, + 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, + 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() + onSearch(uiState.searchQuery.text.toString()) + } + ) + ) + + + 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") + } + ) + } + } + } + } + } + + + } + } +} + +@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/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/ProfileScreen.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt new file mode 100644 index 0000000..48a8922 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/ProfileScreen.kt @@ -0,0 +1,399 @@ +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 +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 +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.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.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 +import com.kdbrian.sage.ui.composables.CircularProfileAvatar +import com.kdbrian.sage.ui.composables.RowButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + navHostController: NavHostController +) { + + var isLoggedIn by remember { + mutableStateOf(false) + } + + + Scaffold( + topBar = { + Surface( + shadowElevation = 2.dp + ) { + TopAppBar( + title = { Text(text = "Profile & Settings.") }, + navigationIcon = { + IconButton(onClick = { + navHostController.popBackStack() + }) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + } + ) + } + } + ) { paddingValues -> + Box( + modifier = Modifier.padding(paddingValues), + ) { + AnimatedContent(isLoggedIn) { + if (it) { + LazyColumn( + modifier = Modifier + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + + item { + Row( + modifier = Modifier, + 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 + ) + ) + + + 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.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.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) + ) + + } + } + + + 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)) + + } + } + + + } + } else { + GetStarted( + onSkip = { + navHostController.popBackStack() + } + ) + } + + } + } + } + +} + +@Preview +@Composable +private fun ProfileScreenPrev() { + App { +// ProfileScreen() + } +} \ No newline at end of file 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/screens/TopicDetails.kt b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt new file mode 100644 index 0000000..311b471 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/screens/TopicDetails.kt @@ -0,0 +1,210 @@ +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.foundation.lazy.itemsIndexed +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.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 +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() + + val isFavorite by remember { + derivedStateOf { + uiState.favouriteTopics.any { + it == uiState.topicDetails?.topicId + } + } + } + + val icon = remember { + derivedStateOf { + if (isFavorite) + Icons.Rounded.Favorite + else + Icons.Rounded.FavoriteBorder + } + } + + + Scaffold( + topBar = { + Surface( + shadowElevation = 3.dp + ) { + CenterAlignedTopAppBar( + title = { + Text( + 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 = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + val tint by animateColorAsState( + if (isFavorite) + Color.Red + else + Color.Black + ) + + IconButton(onClick = topicDetailsViewModel::onFavourite) { + Icon( + tint = tint, + imageVector = icon.value, + contentDescription = null + ) + } + } + ) + } + } + ) { + LazyColumn( + modifier = Modifier + .padding(it) + .padding(12.dp) + ) { + 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 { + 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/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..ecdda63 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenUiState.kt @@ -0,0 +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 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 new file mode 100644 index 0000000..b2031c3 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/CreateScreenViewModel.kt @@ -0,0 +1,176 @@ +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 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, + private val documentRepo: DocumentRepo, +) : ViewModel() { + + + private val _mutableState = MutableStateFlow(CreateScreenUiState()) + val uiState = _mutableState.asStateFlow() + + private val messageChannel = Channel() + val messages = messageChannel.receiveAsFlow() + + + 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 + + } + + @androidx.annotation.RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + 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.") + 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(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 + 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() + messageChannel.close() + } + + +} \ No newline at end of file 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/HomeScreenUiState.kt b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt new file mode 100644 index 0000000..4301ebe --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenUiState.kt @@ -0,0 +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 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 new file mode 100644 index 0000000..ef3b7c5 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/HomeScreenViewModel.kt @@ -0,0 +1,60 @@ +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 com.kdbrian.sage.ui.util.UiTimeOut +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +class HomeScreenViewModel( + private val topicRepo: TopicRepo +) : ViewModel() { + + private val _mutableState: MutableStateFlow = + MutableStateFlow(HomeScreenUiState()) + 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}") + delay(UiTimeOut.timeOut) + _mutableState.value = _mutableState.value.copy( + topics = it, + isLoading = false, + message = "Loaded ${it.size} topics." + ) + } + + 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 + ) + } + + + } + } + +} \ No newline at end of file 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..91b0bf3 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/SearchResultsScreenViewModel.kt @@ -0,0 +1,109 @@ +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 +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() + + 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.timeOut) + + + _mutableState.value = _mutableState.value.copy( + isLoading = false, + documentsFromUploaded = it + ) + }, + onFailure = { + Timber.d("failed: ${it.message}") + messageChannel.send(it.message.toString()) + delay(UiTimeOut.timeOut) + _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.timeOut) + _mutableState.value = _mutableState.value.copy( + isLoading = false, + documentsFromUploaded = it + ) + }, + onFailure = { + Timber.tag("documentsByQuery").d("failed: ${it.message}") + messageChannel.send(it.message.toString()) + delay(UiTimeOut.timeOut) + _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 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..a7f5e7b --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/ui/state/TopicDetailsViewModel.kt @@ -0,0 +1,103 @@ +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}") + + 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/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/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 + ) + ) + } +} 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/AppDataStore.kt b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt new file mode 100644 index 0000000..198cb3a --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/AppDataStore.kt @@ -0,0 +1,57 @@ +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") + + +class AppDataStore( + private val context: Context +) { + + //first time + private val _isFirstTime = booleanPreferencesKey(Companion.firstTime) + suspend fun updateFirstTime(firstTime: Boolean) { + context.appDataStore.edit { preferences -> + preferences[_isFirstTime] = firstTime + } + } + + val firstTime = context.appDataStore.data.map { preferences -> + preferences[_isFirstTime] ?: true + } + + //fav topics + private val _favouriteTopics = stringPreferencesKey(Companion.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] ?: "[]") + decodeFromString.toMutableSet() + } + + companion object { + private const val firstTime = "isFirstTime" + private const val favouriteTopics = "favouriteTopics" + } + +} \ 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/java/com/kdbrian/sage/util/FileUtils.kt b/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt new file mode 100644 index 0000000..581fd74 --- /dev/null +++ b/app/src/main/java/com/kdbrian/sage/util/FileUtils.kt @@ -0,0 +1,47 @@ +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 + } + + + 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/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/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/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/java/com/kdbrian/templated/MainActivity.kt b/app/src/main/java/com/kdbrian/templated/MainActivity.kt deleted file mode 100644 index b49fac8..0000000 --- a/app/src/main/java/com/kdbrian/templated/MainActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.kdbrian.templated - -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 com.kdbrian.templated.ui.theme.TemplatedTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - TemplatedTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } - } - } - } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello ${BuildConfig.ENV}!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - TemplatedTheme { - 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/templated/TemplatedApp.kt deleted file mode 100644 index ffdcc5e..0000000 --- a/app/src/main/java/com/kdbrian/templated/TemplatedApp.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.kdbrian.templated - -import android.app.Application -import timber.log.Timber - -class TemplatedApp : Application() { - override fun onCreate() { - super.onCreate() - - if (BuildConfig.DEBUG) - Timber.plant(Timber.DebugTree()) - } -} \ No newline at end of file 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 0000000..a5f714d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/current_premium.webp differ 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 0000000..bbe912f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/paper_avalanche.jpg differ 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 @@ + + + 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 @@ + + + + + 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/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27bdf3c..cf98e96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ - Templated + Sage + App Notifications \ No newline at end of file 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/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/build.gradle.kts b/build.gradle.kts index 1a19b15..0042bc9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,13 @@ 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 { 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 + 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 d5d3e97..07eefc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,41 @@ [versions] +accompanistPermissions = "0.37.3" agp = "8.10.1" -koinBom = "4.0.3" -kotlin = "2.0.21" +coilCompose = "3.3.0" +coilNetworkOkhttp = "3.3.0" +datastorePreferencesCore = "1.1.7" +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" +kotlinxSerializationJson = "1.9.0" +libraryBase = "3.0.0" +lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" -composeBom = "2024.09.00" +composeBom = "2025.07.00" junitJupiter = "5.8.1" -junitJunit = "4.12" +materialIconsExtended = "1.7.8" +navigationCompose = "2.9.3" +secretsGradlePlugin = "2.0.1" timber = "5.0.1" +googleGmsGoogleServices = "4.4.3" +firebaseAnalytics = "23.0.0" +firebaseFirestore = "26.0.0" +firebaseStorage = "22.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" } +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" } @@ -30,16 +50,22 @@ 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" } +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" } 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" } +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" } 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" } 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")