diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..40c9d8f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*.{kt,kts}] +max_line_length = 160 +ktlint_code_style = ktlint_official +ktlint_standard_chain-method-continuation = disabled +ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_no-single-line-block-comment = disabled +ktlint_standard_parameter-list-wrapping = enabled +ktlint_standard_function-expression-body = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable,Preview,PreviewLightDark +ktlint_function_signature_body_expression_wrapping = default diff --git a/app/.editorconfig b/app/.editorconfig index 33639b9..40c9d8f 100644 --- a/app/.editorconfig +++ b/app/.editorconfig @@ -1,11 +1,13 @@ root = true [*.{kt,kts}] -ktlint_code_style = ktlint_official -ktlint_function_naming_ignore_when_annotated_with=Composable max_line_length = 160 - -## Testing rules -[**/{test,androidTest,commonTest}/**.kt] -max_line_length = off -spacing-between-declarations-with-comments = off +ktlint_code_style = ktlint_official +ktlint_standard_chain-method-continuation = disabled +ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_no-single-line-block-comment = disabled +ktlint_standard_parameter-list-wrapping = enabled +ktlint_standard_function-expression-body = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable,Preview,PreviewLightDark +ktlint_function_signature_body_expression_wrapping = default diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 71674b4..c621f8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,17 +10,17 @@ plugins { } ksp { - arg("KOIN_CONFIG_CHECK", "true") + arg("KOIN_CONFIG_CHECK", "false") } android { namespace = "net.frozendevelopment.openletters" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "net.frozendevelopment.openletters" minSdk = 26 - targetSdk = 35 + targetSdk = 36 versionCode = Integer.parseInt(System.getenv("GITHUB_RUN_NUMBER") ?: "1") versionName = "0.1.0" @@ -79,9 +79,6 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - } buildFeatures { compose = true } @@ -92,6 +89,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + tasks.register("printVersionName") { doLast { println(android.defaultConfig.versionName) @@ -118,24 +121,27 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material3.adaptive.navigation) implementation(libs.androidx.material.icons) implementation(libs.androidx.material.icons.extended) - implementation(libs.androidx.navigation) - implementation(libs.androidx.navigation.common.ktx) + implementation(libs.androidx.nav3.runtime) + implementation(libs.androidx.nav3.ui) implementation(libs.androidx.lifecycle.runtime.compose.android) + implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.splashscreen) implementation(platform(libs.koin.bom)) implementation(libs.koin.android) implementation(libs.koin.compose) implementation(libs.koin.workmanager) - implementation(libs.koin.compose.navigation) + implementation(libs.koin.nav3) + implementation(libs.androidx.adaptive.android) implementation(libs.androidx.core.animation) implementation(libs.androidx.ui.text.google.fonts) implementation(libs.androidx.datastore.core.android) - compileOnly(libs.koin.annotations) - ksp(libs.koin.ksp) +// compileOnly(libs.koin.annotations) +// ksp(libs.koin.ksp) implementation(libs.sqldelight) implementation(libs.sqldelight.coroutines) diff --git a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt index ee33708..8eec897 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt @@ -1,7 +1,9 @@ package net.frozendevelopment.openletters -import android.content.pm.ActivityInfo +import android.content.ActivityNotFoundException +import android.content.Intent import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -13,46 +15,53 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.Scaffold +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController -import androidx.window.core.layout.WindowWidthSizeClass +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.launch -import net.frozendevelopment.openletters.data.sqldelight.LetterQueries -import net.frozendevelopment.openletters.extensions.newRoot -import net.frozendevelopment.openletters.feature.category.categories +import net.frozendevelopment.openletters.extensions.EntryProvider +import net.frozendevelopment.openletters.extensions.koinEntryProvider import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination import net.frozendevelopment.openletters.feature.category.manage.ManageCategoryDestination -import net.frozendevelopment.openletters.feature.letter.letters import net.frozendevelopment.openletters.feature.letter.list.LetterListDestination -import net.frozendevelopment.openletters.feature.letter.scan.ScanLetterDestination import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination import net.frozendevelopment.openletters.feature.reminder.list.ReminderListDestination -import net.frozendevelopment.openletters.feature.reminder.reminders import net.frozendevelopment.openletters.feature.settings.SettingsDestination -import net.frozendevelopment.openletters.feature.settings.settings -import net.frozendevelopment.openletters.ui.animation.navigationEnterTransition -import net.frozendevelopment.openletters.ui.animation.navigationExitTransition -import net.frozendevelopment.openletters.ui.animation.navigationPopEnterTransition -import net.frozendevelopment.openletters.ui.animation.navigationPopExitTransition -import net.frozendevelopment.openletters.ui.components.LettersNavDrawer +import net.frozendevelopment.openletters.ui.animation.popTransitionSpec +import net.frozendevelopment.openletters.ui.animation.pushTransitionSpec +import net.frozendevelopment.openletters.ui.navigation.LettersNavDrawer +import net.frozendevelopment.openletters.ui.navigation.LocalDrawerState +import net.frozendevelopment.openletters.ui.navigation.LocalNavigationState +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator +import net.frozendevelopment.openletters.ui.navigation.Navigator +import net.frozendevelopment.openletters.ui.navigation.rememberNavigationState +import net.frozendevelopment.openletters.ui.navigation.toEntries import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme import net.frozendevelopment.openletters.util.ThemeManagerType import org.koin.android.ext.android.inject +import org.koin.core.annotation.KoinExperimentalAPI class MainActivity : ComponentActivity() { private val themeManager: ThemeManagerType by inject() - private val letterQueries: LetterQueries by inject() + @OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3AdaptiveApi::class) override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -61,77 +70,108 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - val currentTheme by themeManager.current.collectAsStateWithLifecycle() + App() + } + } - OpenLettersTheme( - appTheme = currentTheme.first, - colorPalette = currentTheme.second, - ) { - val coroutineScope = rememberCoroutineScope() - val drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val navHostController = rememberNavController() + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + @Composable + private fun App() { + val currentTheme by themeManager.current.collectAsStateWithLifecycle() - // lock the app to portrait for phone users - if (currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } + val coroutineScope = rememberCoroutineScope() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val navigationState = rememberNavigationState( + LetterListDestination, + setOf( + LetterListDestination, + ManageCategoryDestination, + ReminderListDestination, + ), + ) + val navigator = remember { + Navigator( + state = navigationState, + backPressedDispatcher = onBackPressedDispatcher, + openInBrowser = { + try { + startActivity(Intent(Intent.ACTION_VIEW, it.toUri())) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, "No browser found", Toast.LENGTH_SHORT).show() + } + }, + ) + } + val entryProvider: EntryProvider = koinEntryProvider() + + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp) + } - LettersNavDrawer( - drawerState = drawerState, - goToMail = { - coroutineScope.launch { drawerState.close() } - navHostController.newRoot(LetterListDestination) - }, - goToManageCategories = { - coroutineScope.launch { drawerState.close() } - navHostController.newRoot(ManageCategoryDestination) - }, - goToCreateCategory = { - coroutineScope.launch { drawerState.close() } - navHostController.navigate(CategoryFormDestination()) - }, - goToReminders = { - coroutineScope.launch { drawerState.close() } - navHostController.newRoot(ReminderListDestination) - }, - goToCreateReminder = { - coroutineScope.launch { drawerState.close() } - navHostController.navigate(ReminderFormDestination()) - }, - goToSettings = { - coroutineScope.launch { drawerState.close() } - navHostController.navigate(SettingsDestination) - }, - ) { - Scaffold(modifier = Modifier.fillMaxSize()) { _ -> - Box( - modifier = - Modifier - .fillMaxSize() - .statusBarsPadding() - .windowInsetsPadding( - WindowInsets.safeDrawing.only( - WindowInsetsSides.Horizontal, - ), + val supportingPaneStrategy = rememberListDetailSceneStrategy( + backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange, + directive = directive, + ) + + OpenLettersTheme( + appTheme = currentTheme.first, + colorPalette = currentTheme.second, + ) { + LettersNavDrawer( + drawerState = drawerState, + goToMail = { + coroutineScope.launch { drawerState.close() } + navigator.navigate(LetterListDestination) + }, + goToManageCategories = { + coroutineScope.launch { drawerState.close() } + navigator.navigate(ManageCategoryDestination) + }, + goToCreateCategory = { + coroutineScope.launch { drawerState.close() } + navigator.navigate(CategoryFormDestination()) + }, + goToReminders = { + coroutineScope.launch { drawerState.close() } + navigator.navigate(ReminderListDestination) + }, + goToCreateReminder = { + coroutineScope.launch { drawerState.close() } + navigator.navigate(ReminderFormDestination()) + }, + goToSettings = { + coroutineScope.launch { drawerState.close() } + navigator.navigate(SettingsDestination) + }, + ) { + Scaffold(modifier = Modifier.fillMaxSize()) { _ -> + Box( + modifier = + Modifier + .fillMaxSize() + .statusBarsPadding() + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, ), - ) { - NavHost( - navController = navHostController, - startDestination = - if (letterQueries.hasLetters().executeAsOne() == 1L) { - LetterListDestination - } else { - ScanLetterDestination(canNavigateBack = false) - }, - enterTransition = { navigationEnterTransition() }, - exitTransition = { navigationExitTransition() }, - popEnterTransition = { navigationPopEnterTransition() }, - popExitTransition = { navigationPopExitTransition() }, - ) { - categories(navHostController, drawerState) - letters(navHostController, drawerState) - reminders(navHostController, drawerState) - settings(navHostController) + ), + ) { + CompositionLocalProvider(LocalDrawerState provides drawerState) { + CompositionLocalProvider(LocalNavigationState provides navigationState) { + CompositionLocalProvider( + LocalNavigator provides navigator, + ) { + NavDisplay( + entries = navigationState.toEntries(entryProvider), + sceneStrategy = supportingPaneStrategy, + onBack = { navigator.pop() }, + transitionSpec = { pushTransitionSpec() }, + popTransitionSpec = { popTransitionSpec() }, + predictivePopTransitionSpec = { popTransitionSpec() }, + ) + } } } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/OpenLettersApplication.kt b/app/src/main/java/net/frozendevelopment/openletters/OpenLettersApplication.kt index 9e3293d..27f1f68 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/OpenLettersApplication.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/OpenLettersApplication.kt @@ -5,18 +5,20 @@ import android.app.NotificationChannel import android.app.NotificationManager import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import net.frozendevelopment.openletters.data.sqldelight.SqlDelightKoin -import net.frozendevelopment.openletters.feature.FeatureKoin -import net.frozendevelopment.openletters.migration.AppMigrationKoin -import net.frozendevelopment.openletters.usecase.UseCaseKoin -import net.frozendevelopment.openletters.util.UtilKoin +import net.frozendevelopment.openletters.data.sqldelight.sqlDelightKoinModule +import net.frozendevelopment.openletters.feature.category.categoryKoinModule +import net.frozendevelopment.openletters.feature.letter.letterKoinModule +import net.frozendevelopment.openletters.feature.reminder.reminderKoinModule +import net.frozendevelopment.openletters.feature.settings.settingsKoinModule +import net.frozendevelopment.openletters.migration.appMigrationKoinModule +import net.frozendevelopment.openletters.usecase.useCaseKoinModule +import net.frozendevelopment.openletters.util.utilKoinModule import net.frozendevelopment.openletters.work.AppMigrationWorker -import net.frozendevelopment.openletters.work.WorkKoin +import net.frozendevelopment.openletters.work.workKoinModule import org.koin.android.ext.koin.androidContext import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.core.component.KoinComponent import org.koin.core.context.startKoin -import org.koin.ksp.generated.module class OpenLettersApplication : Application(), @@ -28,12 +30,21 @@ class OpenLettersApplication : androidContext(this@OpenLettersApplication) workManagerFactory() modules( - SqlDelightKoin().module, - FeatureKoin().module, - UseCaseKoin().module, - AppMigrationKoin().module, - WorkKoin().module, - UtilKoin().module, + // SqlDelightKoin().module, + sqlDelightKoinModule, + // FeatureKoin().module, + letterKoinModule, + categoryKoinModule, + reminderKoinModule, + settingsKoinModule, + // UseCaseKoin().module, + useCaseKoinModule, + // AppMigrationKoin().module, + appMigrationKoinModule, +// WorkKoin().module, + workKoinModule, + // UtilKoin().module, + utilKoinModule, ) } diff --git a/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/SqlDelightKoin.kt b/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/SqlDelightKoin.kt index 8ad2ff8..cf79b1e 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/SqlDelightKoin.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/SqlDelightKoin.kt @@ -1,95 +1,108 @@ package net.frozendevelopment.openletters.data.sqldelight -import android.content.Context import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory -import net.frozendevelopment.openletters.data.sqldelight.migrations.Category -import net.frozendevelopment.openletters.data.sqldelight.migrations.Document -import net.frozendevelopment.openletters.`data`.sqldelight.migrations.Letter -import net.frozendevelopment.openletters.data.sqldelight.migrations.LetterToCategory -import net.frozendevelopment.openletters.data.sqldelight.migrations.LetterToReminder -import net.frozendevelopment.openletters.data.sqldelight.migrations.Reminder -import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId -import net.frozendevelopment.openletters.data.sqldelight.models.ColorAdapter -import net.frozendevelopment.openletters.data.sqldelight.models.DocumentId -import net.frozendevelopment.openletters.data.sqldelight.models.LetterId -import net.frozendevelopment.openletters.data.sqldelight.models.LocalDateTimeAdapter -import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId -import org.koin.core.annotation.Factory -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single +import net.frozendevelopment.openletters.extensions.invoke +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +// import org.koin.core.annotation.Factory +// import org.koin.core.annotation.Module +// import org.koin.core.annotation.Single -@Module -class SqlDelightKoin { - @Single - fun databaseDriver(context: Context): SqlDriver = - AndroidSqliteDriver( - schema = OpenLettersDB.Schema, - context = context, - name = "openletters.db", - factory = RequerySQLiteOpenHelperFactory(), - ) +// @Module +// class SqlDelightKoin { +// // @Single +// fun databaseDriver(context: Context): SqlDriver = +// AndroidSqliteDriver( +// schema = OpenLettersDB.Schema, +// context = context, +// name = "openletters.db", +// factory = RequerySQLiteOpenHelperFactory(), +// ) +// +// // @Single +// fun openLettersDB(driver: SqlDriver): OpenLettersDB { +// val appDriver = +// driver.apply { +// execute(null, "PRAGMA foreign_keys = ON;", 0) +// } +// +// return OpenLettersDB( +// driver = appDriver, +// letterAdapter = +// Letter.Adapter( +// idAdapter = LetterId.adapter, +// createdAdapter = LocalDateTimeAdapter, +// lastModifiedAdapter = LocalDateTimeAdapter, +// ), +// documentAdapter = +// Document.Adapter( +// idAdapter = DocumentId.adapter, +// letterIdAdapter = LetterId.adapter, +// ), +// categoryAdapter = +// Category.Adapter( +// idAdapter = CategoryId.adapter, +// colorAdapter = ColorAdapter, +// createdAdapter = LocalDateTimeAdapter, +// lastModifiedAdapter = LocalDateTimeAdapter, +// ), +// letterToCategoryAdapter = +// LetterToCategory.Adapter( +// letterIdAdapter = LetterId.adapter, +// categoryIdAdapter = CategoryId.adapter, +// ), +// reminderAdapter = +// Reminder.Adapter( +// idAdapter = ReminderId.adapter, +// createdAdapter = LocalDateTimeAdapter, +// lastModifiedAdapter = LocalDateTimeAdapter, +// scheduledForAdapter = LocalDateTimeAdapter, +// ), +// letterToReminderAdapter = +// LetterToReminder.Adapter( +// letterIdAdapter = LetterId.adapter, +// reminderIdAdapter = ReminderId.adapter, +// ), +// ) +// } +// +// // @Factory +// fun reminderQueries(openLettersDB: OpenLettersDB) = openLettersDB.reminderQueries +// +// // @Factory +// fun letterQueries(openLettersDB: OpenLettersDB) = openLettersDB.letterQueries +// +// // @Factory +// fun categoryQueries(openLettersDB: OpenLettersDB) = openLettersDB.categoryQueries +// +// // @Factory +// fun documentQueries(openLettersDB: OpenLettersDB) = openLettersDB.documentQueries +// +// // @Factory +// fun appMigrationQueries(openLettersDB: OpenLettersDB) = openLettersDB.appMigrationQueries +// } - @Single - fun openLettersDB(driver: SqlDriver): OpenLettersDB { - val appDriver = - driver.apply { - execute(null, "PRAGMA foreign_keys = ON;", 0) - } +val sqlDelightKoinModule = + module { + single { + AndroidSqliteDriver( + schema = OpenLettersDB.Schema, + context = androidContext(), + name = "openletters.db", + factory = RequerySQLiteOpenHelperFactory(), + ) + } - return OpenLettersDB( - driver = appDriver, - letterAdapter = - Letter.Adapter( - idAdapter = LetterId.adapter, - createdAdapter = LocalDateTimeAdapter, - lastModifiedAdapter = LocalDateTimeAdapter, - ), - documentAdapter = - Document.Adapter( - idAdapter = DocumentId.adapter, - letterIdAdapter = LetterId.adapter, - ), - categoryAdapter = - Category.Adapter( - idAdapter = CategoryId.adapter, - colorAdapter = ColorAdapter, - createdAdapter = LocalDateTimeAdapter, - lastModifiedAdapter = LocalDateTimeAdapter, - ), - letterToCategoryAdapter = - LetterToCategory.Adapter( - letterIdAdapter = LetterId.adapter, - categoryIdAdapter = CategoryId.adapter, - ), - reminderAdapter = - Reminder.Adapter( - idAdapter = ReminderId.adapter, - createdAdapter = LocalDateTimeAdapter, - lastModifiedAdapter = LocalDateTimeAdapter, - scheduledForAdapter = LocalDateTimeAdapter, - ), - letterToReminderAdapter = - LetterToReminder.Adapter( - letterIdAdapter = LetterId.adapter, - reminderIdAdapter = ReminderId.adapter, - ), - ) - } - - @Factory - fun reminderQueries(openLettersDB: OpenLettersDB) = openLettersDB.reminderQueries - - @Factory - fun letterQueries(openLettersDB: OpenLettersDB) = openLettersDB.letterQueries + single { + // Enable foreign keys on the driver instance used by the DB + OpenLettersDB(get()) + } - @Factory - fun categoryQueries(openLettersDB: OpenLettersDB) = openLettersDB.categoryQueries - - @Factory - fun documentQueries(openLettersDB: OpenLettersDB) = openLettersDB.documentQueries - - @Factory - fun appMigrationQueries(openLettersDB: OpenLettersDB) = openLettersDB.appMigrationQueries -} + factory { get().reminderQueries } + factory { get().letterQueries } + factory { get().categoryQueries } + factory { get().documentQueries } + factory { get().appMigrationQueries } + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt b/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt new file mode 100644 index 0000000..5925c8a --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt @@ -0,0 +1,25 @@ +package net.frozendevelopment.openletters.extensions + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import org.koin.compose.LocalKoinScopeContext +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.annotation.KoinInternalApi +import org.koin.core.scope.Scope + +typealias EntryProvider = (NavKey) -> NavEntry + +@OptIn(KoinExperimentalAPI::class, KoinInternalApi::class) +@KoinExperimentalAPI +@Composable +fun koinEntryProvider(scope: Scope = LocalKoinScopeContext.current.getValue()): EntryProvider { + val entries: List.() -> Unit> = scope.getAll() + val entryProvider: (NavKey) -> NavEntry = + entryProvider { + entries.forEach { builder -> this.builder() } + } + return entryProvider +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/extensions/NavControllerExtensions.kt b/app/src/main/java/net/frozendevelopment/openletters/extensions/NavControllerExtensions.kt deleted file mode 100644 index 755b4d2..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/extensions/NavControllerExtensions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package net.frozendevelopment.openletters.extensions - -import android.content.Intent -import android.net.Uri -import androidx.navigation.NavController - -fun NavController.newRoot(destination: T) { - if (currentDestination?.route == destination::class.java.name) { - return - } - - this.navigate(route = destination) { - popUpTo(graph.id) { - inclusive = true - } - } -} - -fun NavController.openUrl(url: String) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) -} diff --git a/app/src/main/java/net/frozendevelopment/openletters/extensions/OpenLettersDBExtensions.kt b/app/src/main/java/net/frozendevelopment/openletters/extensions/OpenLettersDBExtensions.kt new file mode 100644 index 0000000..1405328 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/extensions/OpenLettersDBExtensions.kt @@ -0,0 +1,58 @@ +package net.frozendevelopment.openletters.extensions + +import app.cash.sqldelight.db.SqlDriver +import net.frozendevelopment.openletters.data.sqldelight.OpenLettersDB +import net.frozendevelopment.openletters.data.sqldelight.migrations.Category +import net.frozendevelopment.openletters.data.sqldelight.migrations.Document +import net.frozendevelopment.openletters.data.sqldelight.migrations.Letter +import net.frozendevelopment.openletters.data.sqldelight.migrations.LetterToCategory +import net.frozendevelopment.openletters.data.sqldelight.migrations.LetterToReminder +import net.frozendevelopment.openletters.data.sqldelight.migrations.Reminder +import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId +import net.frozendevelopment.openletters.data.sqldelight.models.ColorAdapter +import net.frozendevelopment.openletters.data.sqldelight.models.DocumentId +import net.frozendevelopment.openletters.data.sqldelight.models.LetterId +import net.frozendevelopment.openletters.data.sqldelight.models.LocalDateTimeAdapter +import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId + +operator fun OpenLettersDB.Companion.invoke(driver: SqlDriver): OpenLettersDB { + driver.execute(null, "PRAGMA foreign_keys = ON;", 0) + return OpenLettersDB( + driver = driver, + letterAdapter = + Letter.Adapter( + idAdapter = LetterId.adapter, + createdAdapter = LocalDateTimeAdapter, + lastModifiedAdapter = LocalDateTimeAdapter, + ), + documentAdapter = + Document.Adapter( + idAdapter = DocumentId.adapter, + letterIdAdapter = LetterId.adapter, + ), + categoryAdapter = + Category.Adapter( + idAdapter = CategoryId.adapter, + colorAdapter = ColorAdapter, + createdAdapter = LocalDateTimeAdapter, + lastModifiedAdapter = LocalDateTimeAdapter, + ), + letterToCategoryAdapter = + LetterToCategory.Adapter( + letterIdAdapter = LetterId.adapter, + categoryIdAdapter = CategoryId.adapter, + ), + reminderAdapter = + Reminder.Adapter( + idAdapter = ReminderId.adapter, + createdAdapter = LocalDateTimeAdapter, + lastModifiedAdapter = LocalDateTimeAdapter, + scheduledForAdapter = LocalDateTimeAdapter, + ), + letterToReminderAdapter = + LetterToReminder.Adapter( + letterIdAdapter = LetterId.adapter, + reminderIdAdapter = ReminderId.adapter, + ), + ) +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/FeatureKoin.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/FeatureKoin.kt index de56477..efb57cc 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/FeatureKoin.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/FeatureKoin.kt @@ -1,147 +1,28 @@ package net.frozendevelopment.openletters.feature -import android.app.Application -import net.frozendevelopment.openletters.data.sqldelight.CategoryQueries -import net.frozendevelopment.openletters.data.sqldelight.LetterQueries -import net.frozendevelopment.openletters.data.sqldelight.ReminderQueries -import net.frozendevelopment.openletters.data.sqldelight.models.LetterId -import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId -import net.frozendevelopment.openletters.feature.category.form.CategoryFormMode -import net.frozendevelopment.openletters.feature.category.form.CategoryFormViewModel -import net.frozendevelopment.openletters.feature.category.manage.ManageCategoryViewModel -import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailViewModel -import net.frozendevelopment.openletters.feature.letter.list.LetterListViewModel -import net.frozendevelopment.openletters.feature.letter.peek.LetterPeekViewModel -import net.frozendevelopment.openletters.feature.letter.scan.ScanViewModel -import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailViewModel -import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormViewModel -import net.frozendevelopment.openletters.feature.reminder.list.ReminderListViewModel -import net.frozendevelopment.openletters.feature.settings.SettingsViewModel -import net.frozendevelopment.openletters.usecase.AcknowledgeReminderUseCase -import net.frozendevelopment.openletters.usecase.DeleteLetterUseCase -import net.frozendevelopment.openletters.usecase.DeleteReminderUseCase -import net.frozendevelopment.openletters.usecase.LetterWithDetailsUseCase -import net.frozendevelopment.openletters.usecase.ReminderWithDetailsUseCase -import net.frozendevelopment.openletters.usecase.SaveCategoryOrderUseCase -import net.frozendevelopment.openletters.usecase.SearchLettersUseCase -import net.frozendevelopment.openletters.usecase.UpsertCategoryUseCase -import net.frozendevelopment.openletters.usecase.UpsertLetterUseCase -import net.frozendevelopment.openletters.usecase.UpsertReminderUseCase -import net.frozendevelopment.openletters.util.TextExtractorType -import net.frozendevelopment.openletters.util.ThemeManagerType -import org.koin.android.annotation.KoinViewModel -import org.koin.core.annotation.InjectedParam -import org.koin.core.annotation.Module - -@Module -class FeatureKoin { - @KoinViewModel - fun letterListViewModel( - reminderQueries: ReminderQueries, - letterQueries: LetterQueries, - searchUseCase: SearchLettersUseCase, - categoryQueries: CategoryQueries, - deleteLetterUseCase: DeleteLetterUseCase, - ) = LetterListViewModel( - reminderQueries = reminderQueries, - letterQueries = letterQueries, - searchUseCase = searchUseCase, - categoryQueries = categoryQueries, - deleteLetter = deleteLetterUseCase, - ) - - @KoinViewModel - fun scanViewModel( - @InjectedParam letterToEdit: LetterId?, - letterQueries: LetterQueries, - textExtractor: TextExtractorType, - createLetter: UpsertLetterUseCase, - categoryQueries: CategoryQueries, - letterWithDetailsUseCase: LetterWithDetailsUseCase, - ) = ScanViewModel( - letterToEdit = letterToEdit, - letterQueries = letterQueries, - textExtractor = textExtractor, - createLetter = createLetter, - categoryQueries = categoryQueries, - letterWithDetails = letterWithDetailsUseCase, - ) - - @KoinViewModel - fun categoryFormViewModel( - @InjectedParam mode: CategoryFormMode, - upsertCategoryUseCase: UpsertCategoryUseCase, - categoryQueries: CategoryQueries, - ) = CategoryFormViewModel( - mode = mode, - upsertCategoryUseCase = upsertCategoryUseCase, - categoryQueries = categoryQueries, - ) - - @KoinViewModel - fun manageCategoryViewModel( - saveCategoryOrderUseCase: SaveCategoryOrderUseCase, - categoryQueries: CategoryQueries, - ) = ManageCategoryViewModel(saveCategoryOrderUseCase, categoryQueries) - - @KoinViewModel - fun letterDetailViewModel( - @InjectedParam letterId: LetterId, - letterWithDetailsUseCase: LetterWithDetailsUseCase, - ) = LetterDetailViewModel(letterId, letterWithDetailsUseCase) - - @KoinViewModel - fun letterPeekViewModel( - @InjectedParam letterId: LetterId, - letterWithDetails: LetterWithDetailsUseCase, - ) = LetterPeekViewModel(letterId, letterWithDetails) - - @KoinViewModel - fun reminderListViewModel( - application: Application, - reminderQueries: ReminderQueries, - deleteReminder: DeleteReminderUseCase, - ) = ReminderListViewModel( - application = application, - reminderQueries = reminderQueries, - deleteReminder = deleteReminder, - ) - - @KoinViewModel - fun reminderFormViewModel( - @InjectedParam reminderToEdit: ReminderId?, - @InjectedParam preselectedLetters: List, - application: Application, - searchLettersUseCase: SearchLettersUseCase, - upsertReminderUseCase: UpsertReminderUseCase, - reminderQueries: ReminderQueries, - ) = ReminderFormViewModel( - reminderToEdit = reminderToEdit, - application = application, - searchLetters = searchLettersUseCase, - createReminder = upsertReminderUseCase, - reminderQueries = reminderQueries, - preselectedLetters = preselectedLetters, - ) - - @KoinViewModel - fun reminderDetailViewModel( - @InjectedParam reminderId: ReminderId, - application: Application, - reminderWithDetailsUseCase: ReminderWithDetailsUseCase, - acknowledgeReminderUseCase: AcknowledgeReminderUseCase, - ) = ReminderDetailViewModel( - reminderId = reminderId, - application = application, - reminderWithDetails = reminderWithDetailsUseCase, - acknowledgeReminder = acknowledgeReminderUseCase, - ) - - @KoinViewModel - fun settingsViewModel( - application: Application, - themeManager: ThemeManagerType, - ) = SettingsViewModel( - themeManager = themeManager, +import net.frozendevelopment.openletters.feature.category.categoryKoinModule +import net.frozendevelopment.openletters.feature.letter.letterKoinModule +import net.frozendevelopment.openletters.feature.reminder.reminderKoinModule +import net.frozendevelopment.openletters.feature.settings.settingsKoinModule + +// @Module( +// includes = [ +// LetterKoinModule::class, +// CategoryKoinModule::class, +// ReminderKoinModule::class, +// SettingsKoinModule::class +// ] +// ) +class FeatureKoin + +val featureKoinModules = + listOf( + categoryKoinModule, + letterKoinModule, + reminderKoinModule, + settingsKoinModule, +// LetterKoinModule.navigationModule, +// CategoryKoinModule.navigationModule, +// ReminderKoinModule.navigationModule, +// SettingsKoinModule.navigationModule, ) -} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt new file mode 100644 index 0000000..05b2ef8 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt @@ -0,0 +1,56 @@ +package net.frozendevelopment.openletters.feature.category + +import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination +import net.frozendevelopment.openletters.feature.category.form.CategoryFormViewModel +import net.frozendevelopment.openletters.feature.category.form.categoryFormNavigation +import net.frozendevelopment.openletters.feature.category.manage.ManageCategoryViewModel +import net.frozendevelopment.openletters.feature.category.manage.manageCategoryNavigation +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +// @Module +// class CategoryKoinModule { +// // @KoinViewModel +// fun categoryFormViewModel( +// @InjectedParam mode: CategoryFormMode, +// upsertCategoryUseCase: UpsertCategoryUseCase, +// categoryQueries: CategoryQueries, +// ) = CategoryFormViewModel( +// mode = mode, +// upsertCategoryUseCase = upsertCategoryUseCase, +// categoryQueries = categoryQueries, +// ) +// +// // @KoinViewModel +// fun manageCategoryViewModel( +// saveCategoryOrderUseCase: SaveCategoryOrderUseCase, +// categoryQueries: CategoryQueries, +// ) = ManageCategoryViewModel(saveCategoryOrderUseCase, categoryQueries) +// +// companion object { +// @OptIn(KoinExperimentalAPI::class) +// val navigationModule = module { +// categoryFormNavigation() +// manageCategoryNavigation() +// } +// } +// } + +val categoryKoinModule = module { + categoryFormNavigation() + manageCategoryNavigation() + viewModel { (mode: CategoryFormDestination.Mode) -> + CategoryFormViewModel( + mode = mode, + upsertCategoryUseCase = get(), + categoryQueries = get(), + ) + } + + viewModel { + ManageCategoryViewModel( + saveCategoryOrder = get(), + categoryQueries = get(), + ) + } +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryNavigation.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryNavigation.kt deleted file mode 100644 index 846439e..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryNavigation.kt +++ /dev/null @@ -1,82 +0,0 @@ -package net.frozendevelopment.openletters.feature.category - -import androidx.compose.material3.DrawerState -import androidx.compose.material3.Surface -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import kotlinx.coroutines.launch -import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination -import net.frozendevelopment.openletters.feature.category.form.CategoryFormMode -import net.frozendevelopment.openletters.feature.category.form.CategoryFormModeType -import net.frozendevelopment.openletters.feature.category.form.CategoryFormView -import net.frozendevelopment.openletters.feature.category.form.CategoryFormViewModel -import net.frozendevelopment.openletters.feature.category.manage.ManageCategoryDestination -import net.frozendevelopment.openletters.feature.category.manage.ManageCategoryView -import net.frozendevelopment.openletters.feature.category.manage.ManageCategoryViewModel -import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf -import kotlin.reflect.typeOf - -fun NavGraphBuilder.categories( - navController: NavController, - drawerState: DrawerState, -) { - composable( - typeMap = mapOf(typeOf() to CategoryFormModeType), - ) { backStackEntry -> - val destination = backStackEntry.toRoute() - val viewModel: CategoryFormViewModel = koinViewModel { parametersOf(destination.mode) } - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val coroutineScope = rememberCoroutineScope() - - Surface { - CategoryFormView( - state = state, - onLabelChanged = viewModel::setLabel, - onColorChanged = viewModel::setColor, - onBackClicked = navController::popBackStack, - onSaveClicked = { - coroutineScope.launch { - viewModel.save() - navController.popBackStack() - } - }, - ) - } - } - composable { - val viewModel: ManageCategoryViewModel = koinViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val coroutineScope = rememberCoroutineScope() - - Surface { - ManageCategoryView( - state = state, - openNavigationDrawer = { - coroutineScope.launch { - drawerState.apply { - if (isClosed) open() else close() - } - } - }, - editCategoryClicked = { categoryId -> - val mode = - if (categoryId == null) { - CategoryFormMode.Create - } else { - CategoryFormMode.Edit(categoryId) - } - navController.navigate(CategoryFormDestination(mode = mode)) - }, - onDeleteClicked = viewModel::delete, - onMove = viewModel::onMove, - onMoveComplete = viewModel::saveOrder, - ) - } - } -} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormDestination.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormDestination.kt deleted file mode 100644 index aebea97..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormDestination.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.frozendevelopment.openletters.feature.category.form - -import android.os.Build -import android.os.Bundle -import android.os.Parcelable -import androidx.navigation.NavType -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId - -@Serializable -sealed interface CategoryFormMode : Parcelable { - @Serializable - @Parcelize - data object Create : CategoryFormMode - - @Serializable - @Parcelize - data class Edit( - val id: CategoryId, - ) : CategoryFormMode -} - -@Serializable -data class CategoryFormDestination( - val mode: CategoryFormMode = CategoryFormMode.Create, -) - -val CategoryFormModeType = - object : NavType(isNullableAllowed = false) { - override fun get( - bundle: Bundle, - key: String, - ): CategoryFormMode? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - bundle.getParcelable(key, CategoryFormMode::class.java) - } else { - @Suppress("DEPRECATION") - bundle.getParcelable(key) - } - - override fun parseValue(value: String): CategoryFormMode = Json.decodeFromString(value) - - override fun serializeAsValue(value: CategoryFormMode): String = Json.encodeToString(value) - - override fun put( - bundle: Bundle, - key: String, - value: CategoryFormMode, - ) { - bundle.putParcelable(key, value) - } - } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt index af89a45..d09ac4c 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -33,13 +34,62 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey import com.github.skydoves.colorpicker.compose.BrightnessSlider import com.github.skydoves.colorpicker.compose.HsvColorPicker import com.github.skydoves.colorpicker.compose.rememberColorPickerController +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.extensions.Random import net.frozendevelopment.openletters.extensions.contrastColor import net.frozendevelopment.openletters.ui.components.CategoryPill +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.core.parameter.parametersOf +import org.koin.dsl.navigation3.navigation + +@Serializable +data class CategoryFormDestination( + val mode: Mode = Mode.Create, +) : NavKey { + sealed interface Mode { + @Serializable + data object Create : Mode + + @Serializable + data class Edit( + val id: CategoryId, + ) : Mode + } +} + +@OptIn(KoinExperimentalAPI::class) +fun Module.categoryFormNavigation() = navigation { route -> + val navigator = LocalNavigator.current + val viewModel = koinViewModel { parametersOf(route.mode) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + + Surface { + CategoryFormView( + state = state, + onLabelChanged = viewModel::setLabel, + onColorChanged = viewModel::setColor, + onBackClicked = navigator::onBackPressed, + onSaveClicked = { + coroutineScope.launch { + viewModel.save() + navigator.onBackPressed() + } + }, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -171,7 +221,7 @@ private fun CategoryForm() { CategoryFormPreview( state = CategoryFormState( - mode = CategoryFormMode.Create, + mode = CategoryFormDestination.Mode.Create, label = "", color = Color(0xFF0F0FF0), ), diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormViewModel.kt index bfeeb57..3fb51d9 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormViewModel.kt @@ -12,7 +12,7 @@ import net.frozendevelopment.openletters.util.StatefulViewModel @Immutable data class CategoryFormState( - private val mode: CategoryFormMode, + private val mode: CategoryFormDestination.Mode, val isBusy: Boolean = true, val label: String = "", val color: Color = Color.Random, @@ -22,25 +22,25 @@ data class CategoryFormState( val title: String get() = when (mode) { - is CategoryFormMode.Create -> "Create Category" - is CategoryFormMode.Edit -> "Edit Category" + is CategoryFormDestination.Mode.Create -> "Create Category" + is CategoryFormDestination.Mode.Edit -> "Edit Category" } } class CategoryFormViewModel( - private val mode: CategoryFormMode, + private val mode: CategoryFormDestination.Mode, private val upsertCategoryUseCase: UpsertCategoryUseCase, private val categoryQueries: CategoryQueries, ) : StatefulViewModel(CategoryFormState(mode)) { private val categoryId: CategoryId get() = when (mode) { - is CategoryFormMode.Create -> CategoryId.random() - is CategoryFormMode.Edit -> mode.id + is CategoryFormDestination.Mode.Create -> CategoryId.random() + is CategoryFormDestination.Mode.Edit -> mode.id } override fun load() { - if (mode is CategoryFormMode.Edit) { + if (mode is CategoryFormDestination.Mode.Edit) { val category = categoryQueries.get(mode.id).executeAsOneOrNull() if (category != null) { update { @@ -56,15 +56,13 @@ class CategoryFormViewModel( } } - fun setLabel(label: String) = - viewModelScope.launch { - update { copy(label = label) } - } + fun setLabel(label: String) = viewModelScope.launch { + update { copy(label = label) } + } - fun setColor(color: Color) = - viewModelScope.launch { - update { copy(color = color) } - } + fun setColor(color: Color) = viewModelScope.launch { + update { copy(color = color) } + } suspend fun save() { upsertCategoryUseCase( diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryView.kt index 43c49b4..b5634e5 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryView.kt @@ -1,5 +1,8 @@ package net.frozendevelopment.openletters.feature.category.manage +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement @@ -23,6 +26,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,19 +46,71 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.offset import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId +import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination import net.frozendevelopment.openletters.feature.category.manage.ui.CategoryRow import net.frozendevelopment.openletters.feature.category.manage.ui.EmptyCategoryListCell +import net.frozendevelopment.openletters.ui.navigation.LocalDrawerState +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.dsl.navigation3.navigation @Serializable -object ManageCategoryDestination +object ManageCategoryDestination : NavKey private data class DraggableItem( val index: Int, ) +@OptIn(KoinExperimentalAPI::class) +fun Module.manageCategoryNavigation() = navigation( + metadata = NavDisplay.transitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.popTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.predictivePopTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + }, +) { route -> + val drawerState = LocalDrawerState.current + val navigator = LocalNavigator.current + val viewModel = koinViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + + Surface { + ManageCategoryView( + state = state, + openNavigationDrawer = { + coroutineScope.launch { + drawerState.apply { + if (isClosed) open() else close() + } + } + }, + editCategoryClicked = { categoryId -> + val mode = + if (categoryId == null) { + CategoryFormDestination.Mode.Create + } else { + CategoryFormDestination.Mode.Edit(categoryId) + } + navigator.navigate(CategoryFormDestination(mode = mode)) + }, + onDeleteClicked = viewModel::delete, + onMove = viewModel::onMove, + onMoveComplete = viewModel::saveOrder, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ManageCategoryView( diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryViewModel.kt index b0ec376..e63ba27 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/manage/ManageCategoryViewModel.kt @@ -31,11 +31,10 @@ class ManageCategoryViewModel( update { copy(categories = categories) } } - fun delete(category: CategoryId) = - viewModelScope.launch(ioDispatcher) { - categoryQueries.delete(category) - load() - } + fun delete(category: CategoryId) = viewModelScope.launch(ioDispatcher) { + categoryQueries.delete(category) + load() + } fun onMove( from: Int, @@ -59,21 +58,19 @@ class ManageCategoryViewModel( } } - fun saveOrder() = - viewModelScope.launch(ioDispatcher) { - state.categories.forEachIndexed { index, category -> - saveCategoryOrder(category.id, index.toLong()) - } + fun saveOrder() = viewModelScope.launch(ioDispatcher) { + state.categories.forEachIndexed { index, category -> + saveCategoryOrder(category.id, index.toLong()) } + } - fun select(category: CategoryId?) = - viewModelScope.launch { - val selectedCategory = - if (category == state.selectedCategory) { - null - } else { - category - } - update { copy(selectedCategory = selectedCategory) } - } + fun select(category: CategoryId?) = viewModelScope.launch { + val selectedCategory = + if (category == state.selectedCategory) { + null + } else { + category + } + update { copy(selectedCategory = selectedCategory) } + } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterKoinModule.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterKoinModule.kt new file mode 100644 index 0000000..6ae982b --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterKoinModule.kt @@ -0,0 +1,111 @@ +package net.frozendevelopment.openletters.feature.letter + +import androidx.compose.runtime.getValue +import net.frozendevelopment.openletters.data.sqldelight.models.LetterId +import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailViewModel +import net.frozendevelopment.openletters.feature.letter.detail.letterDetailNavigation +import net.frozendevelopment.openletters.feature.letter.image.imageViewNavigation +import net.frozendevelopment.openletters.feature.letter.list.LetterListViewModel +import net.frozendevelopment.openletters.feature.letter.list.letterListNavigation +import net.frozendevelopment.openletters.feature.letter.peek.LetterPeekViewModel +import net.frozendevelopment.openletters.feature.letter.scan.ScanViewModel +import net.frozendevelopment.openletters.feature.letter.scan.scanLetterNavigation +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module +// import org.koin.android.annotation.KoinViewModel +// import org.koin.core.annotation.Module + +// @Module +// class LetterKoinModule { +// // @KoinViewModel +// fun letterListViewModel( +// reminderQueries: ReminderQueries, +// letterQueries: LetterQueries, +// searchUseCase: SearchLettersUseCase, +// categoryQueries: CategoryQueries, +// deleteLetterUseCase: DeleteLetterUseCase, +// ) = LetterListViewModel( +// reminderQueries = reminderQueries, +// letterQueries = letterQueries, +// searchUseCase = searchUseCase, +// categoryQueries = categoryQueries, +// deleteLetter = deleteLetterUseCase, +// ) +// +// // @KoinViewModel +// fun scanViewModel( +// @InjectedParam letterToEdit: LetterId?, +// letterQueries: LetterQueries, +// textExtractor: TextExtractorType, +// createLetter: UpsertLetterUseCase, +// categoryQueries: CategoryQueries, +// letterWithDetailsUseCase: LetterWithDetailsUseCase, +// ) = ScanViewModel( +// letterToEdit = letterToEdit, +// letterQueries = letterQueries, +// textExtractor = textExtractor, +// createLetter = createLetter, +// categoryQueries = categoryQueries, +// letterWithDetails = letterWithDetailsUseCase, +// ) +// +// // @KoinViewModel +// fun letterDetailViewModel( +// @InjectedParam letterId: LetterId, +// letterWithDetailsUseCase: LetterWithDetailsUseCase, +// ) = LetterDetailViewModel(letterId, letterWithDetailsUseCase) +// +// // @KoinViewModel +// fun letterPeekViewModel( +// @InjectedParam letterId: LetterId, +// letterWithDetails: LetterWithDetailsUseCase, +// ) = LetterPeekViewModel(letterId, letterWithDetails) +// +// // Note: reminderListViewModel is provided in ReminderKoinModule DSL to avoid duplication +// +// companion object { +// @OptIn(KoinExperimentalAPI::class) +// val navigationModule = module { +// letterDetailNavigation() +// imageViewNavigation() +// letterListNavigation() +// scanLetterNavigation() +// } +// } +// } + +val letterKoinModule = + module { + letterDetailNavigation() + imageViewNavigation() + letterListNavigation() + scanLetterNavigation() + viewModel { + LetterListViewModel( + reminderQueries = get(), + letterQueries = get(), + searchUseCase = get(), + categoryQueries = get(), + deleteLetter = get(), + ) + } + + viewModel { (letterToEdit: LetterId?) -> + ScanViewModel( + letterToEdit = letterToEdit, + letterQueries = get(), + textExtractor = get(), + createLetter = get(), + categoryQueries = get(), + letterWithDetails = get(), + ) + } + + viewModel { (letterId: LetterId) -> + LetterDetailViewModel(letterId, get()) + } + + viewModel { (letterId: LetterId) -> + LetterPeekViewModel(letterId, get()) + } + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterNavigation.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterNavigation.kt deleted file mode 100644 index 57eaa96..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterNavigation.kt +++ /dev/null @@ -1,244 +0,0 @@ -package net.frozendevelopment.openletters.feature.letter - -import android.app.Activity -import android.app.Activity.RESULT_OK -import android.net.Uri -import android.util.Log -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.DrawerState -import androidx.compose.material3.Surface -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination -import net.frozendevelopment.openletters.feature.category.form.CategoryFormMode -import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailDestination -import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailView -import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailViewModel -import net.frozendevelopment.openletters.feature.letter.image.ImageDestination -import net.frozendevelopment.openletters.feature.letter.image.ImageView -import net.frozendevelopment.openletters.feature.letter.list.LetterListDestination -import net.frozendevelopment.openletters.feature.letter.list.LetterListView -import net.frozendevelopment.openletters.feature.letter.list.LetterListViewModel -import net.frozendevelopment.openletters.feature.letter.scan.ScanLetterDestination -import net.frozendevelopment.openletters.feature.letter.scan.ScanLetterView -import net.frozendevelopment.openletters.feature.letter.scan.ScanViewModel -import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination -import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination -import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf - -fun NavGraphBuilder.letters( - navController: NavController, - drawerState: DrawerState, -) { - composable( - enterTransition = { fadeIn() + scaleIn() }, - exitTransition = { fadeOut() + scaleOut() }, - popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }, - ) { - val destination = it.toRoute() - Surface( - color = Color.Black, - contentColor = Color.White, - ) { - ImageView( - modifier = Modifier.fillMaxSize(), - uri = Uri.parse(destination.uri), - onBackClick = navController::popBackStack, - ) - } - } - composable { - val coroutineScope = rememberCoroutineScope() - val viewModel: LetterListViewModel = koinViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - Surface { - LetterListView( - modifier = Modifier.fillMaxSize(), - state = state, - onNavDrawerClicked = { - coroutineScope.launch { - drawerState.apply { - if (isClosed) open() else close() - } - } - }, - onScanClicked = { navController.navigate(ScanLetterDestination()) }, - toggleCategory = viewModel::toggleCategory, - setSearchTerms = viewModel::setSearchTerms, - openLetter = { id, edit -> - if (edit) { - navController.navigate(ScanLetterDestination(id)) - } else { - navController.navigate(LetterDetailDestination(id)) - } - }, - onDeleteLetterClicked = viewModel::delete, - onReminderClicked = { id, edit -> - if (edit) { - navController.navigate(ReminderDetailDestination(id)) - } else { - navController.navigate(ReminderDetailDestination(id)) - } - }, - onCreateReminderClicked = { navController.navigate(ReminderFormDestination(preselectedLetters = it)) }, - ) - } - } - composable( - typeMap = LetterDetailDestination.typeMap, - ) { backStackEntry -> - val destination = backStackEntry.toRoute() - val viewModel: LetterDetailViewModel = koinViewModel { parametersOf(destination.letterId) } - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - Surface { - LetterDetailView( - modifier = Modifier.fillMaxSize(), - state = state, - onEditClicked = { navController.navigate(ScanLetterDestination(destination.letterId)) }, - onCreateReminderClicked = { - navController.navigate( - ReminderFormDestination(preselectedLetters = listOf(destination.letterId)), - ) - }, - onBackClicked = navController::popBackStack, - onImageClick = { uri -> navController.navigate(ImageDestination(uri.toString())) }, - ) - } - } - composable( - typeMap = ScanLetterDestination.typeMap, - ) { backStackEntry -> - val destination = backStackEntry.toRoute() - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - val viewModel: ScanViewModel = koinViewModel { parametersOf(destination.letterId) } - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - val letterScanLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (result.resultCode == RESULT_OK) { - val scanResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) - viewModel.importScannedDocuments(scanResult) - } - } - - val senderScanLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (result.resultCode == RESULT_OK) { - val scanResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) - viewModel.importScannedSender(scanResult) - } - } - - val recipientScanLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (result.resultCode == RESULT_OK) { - val scanResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) - viewModel.importScannedRecipient(scanResult) - } - } - - Surface { - ScanLetterView( - modifier = - Modifier - .statusBarsPadding() - .navigationBarsPadding(), - state = state, - canNavigateBack = destination.canNavigateBack, - toggleCategory = viewModel::toggleCategory, - setSender = viewModel::setSender, - setRecipient = viewModel::setRecipient, - setTranscript = viewModel::setTranscript, - openLetterScanner = { - val activity = context as? Activity - if (activity != null) { - viewModel - .getScanner() - .getStartScanIntent(activity) - .addOnSuccessListener { intentSender -> - letterScanLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) - }.addOnFailureListener { - Log.e("ScanNavigation", "Scanner failed to load") - } - } - }, - openSenderScanner = { - val activity = context as? Activity - if (activity != null) { - viewModel - .getScanner(pageLimit = 1) - .getStartScanIntent(activity) - .addOnSuccessListener { intentSender -> - senderScanLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) - }.addOnFailureListener { - Log.e("ScanNavigation", "Scanner failed to load") - } - } - }, - openRecipientScanner = { - val activity = context as? Activity - if (activity != null) { - viewModel - .getScanner(pageLimit = 1) - .getStartScanIntent(activity) - .addOnSuccessListener { intentSender -> - recipientScanLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) - }.addOnFailureListener { - Log.e("ScanNavigation", "Scanner failed to load") - } - } - }, - onSaveClicked = { - coroutineScope.launch(Dispatchers.IO) { - if (viewModel.save()) { - withContext(Dispatchers.Main) { - if (destination.canNavigateBack) { - navController.navigateUp() - } else { - navController.navigate(LetterListDestination) { - popUpTo(navController.graph.startDestinationId) { - inclusive = true - } - launchSingleTop = true - restoreState = true - } - } - } - } - } - }, - onBackClicked = navController::navigateUp, - onDeleteDocumentClicked = viewModel::removeDocument, - onCreateCategoryClicked = { navController.navigate(CategoryFormDestination(CategoryFormMode.Create)) }, - ) - } - } -} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailDestination.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailDestination.kt deleted file mode 100644 index 1170549..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailDestination.kt +++ /dev/null @@ -1,42 +0,0 @@ -package net.frozendevelopment.openletters.feature.letter.detail - -import android.os.Bundle -import androidx.navigation.NavType -import kotlinx.serialization.Serializable -import net.frozendevelopment.openletters.data.sqldelight.models.LetterId -import kotlin.reflect.typeOf - -@Serializable -data class LetterDetailDestination( - val letterId: LetterId, -) { - companion object { - val typeMap = - mapOf( - typeOf() to LetterIdNavType, - ) - } -} - -val LetterIdNavType = - object : NavType(isNullableAllowed = false) { - override fun get( - bundle: Bundle, - key: String, - ): LetterId? = - bundle.getString(key).let { - if (it == null) null else LetterId(it) - } - - override fun parseValue(value: String): LetterId = LetterId(value) - - override fun serializeAsValue(value: LetterId): String = value.value - - override fun put( - bundle: Bundle, - key: String, - value: LetterId, - ) { - bundle.putString(key, value.value) - } - } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt index 97b80f6..a878b49 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,16 +44,59 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.mock.mockCategory import net.frozendevelopment.openletters.data.mock.mockLetter import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.DocumentId +import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import net.frozendevelopment.openletters.extensions.dateString +import net.frozendevelopment.openletters.feature.letter.image.ImageDestination +import net.frozendevelopment.openletters.feature.letter.list.LetterListDestination +import net.frozendevelopment.openletters.feature.letter.scan.ScanLetterDestination +import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination import net.frozendevelopment.openletters.ui.components.BrokenImageView import net.frozendevelopment.openletters.ui.components.CategoryPill import net.frozendevelopment.openletters.ui.components.LazyImageView +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.core.parameter.parametersOf +import org.koin.dsl.navigation3.navigation + +@Serializable +data class LetterDetailDestination( + val letterId: LetterId, +) : NavKey + +@OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) +fun Module.letterDetailNavigation() = navigation( + metadata = ListDetailSceneStrategy.detailPane(LetterListDestination::class), +) { route -> + val navigator = LocalNavigator.current + val viewModel: LetterDetailViewModel = koinViewModel { parametersOf(route.letterId) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Surface { + LetterDetailView( + modifier = Modifier.fillMaxSize(), + state = state, + onEditClicked = { navigator.navigate(ScanLetterDestination(route.letterId)) }, + onCreateReminderClicked = { + navigator.navigate( + ReminderFormDestination(preselectedLetters = listOf(route.letterId)), + ) + }, + onBackClicked = { navigator.onBackPressed() }, + onImageClick = { uri -> navigator.navigate(ImageDestination(uri.toString())) }, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/image/ImageView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/image/ImageView.kt index 6e8d3da..4f011ef 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/image/ImageView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/image/ImageView.kt @@ -1,13 +1,15 @@ package net.frozendevelopment.openletters.feature.letter.image -import android.app.Activity import android.content.pm.ActivityInfo import android.net.Uri +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -21,23 +23,42 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntSize +import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import androidx.navigation3.runtime.NavKey import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.ui.components.LazyImageView +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.dsl.navigation3.navigation @Serializable data class ImageDestination( val uri: String, -) +) : NavKey + +@OptIn(KoinExperimentalAPI::class) +fun Module.imageViewNavigation() = navigation { route -> + Surface( + color = Color.Black, + contentColor = Color.White, + ) { + ImageView( + modifier = Modifier.fillMaxSize(), + uri = route.uri.toUri(), + onBackClick = {}, + ) + } +} @Composable fun ImageView( @@ -45,7 +66,7 @@ fun ImageView( uri: Uri, onBackClick: () -> Unit, ) { - val activity = LocalContext.current as? Activity + val activity = LocalActivity.current val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass var offset by remember { mutableStateOf(Offset.Zero) } @@ -134,8 +155,7 @@ fun Offset.new( fun Offset.fromDoubleTap( zoom: Float, size: IntSize, -): Offset = - Offset( - x.coerceIn(0f, (size.width / zoom) * (zoom - 1f)), - y.coerceIn(0f, (size.height / zoom) * (zoom - 1f)), - ) +): Offset = Offset( + x.coerceIn(0f, (size.width / zoom) * (zoom - 1f)), + y.coerceIn(0f, (size.height / zoom) * (zoom - 1f)), +) diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt index da46e49..8955dfc 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt @@ -6,21 +6,83 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId +import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailDestination import net.frozendevelopment.openletters.feature.letter.list.ui.EmptyListView import net.frozendevelopment.openletters.feature.letter.list.ui.LetterList +import net.frozendevelopment.openletters.feature.letter.scan.ScanLetterDestination +import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination +import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination +import net.frozendevelopment.openletters.ui.navigation.LocalDrawerState +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.dsl.navigation3.navigation @Serializable -data object LetterListDestination +data object LetterListDestination : NavKey + +@OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) +fun Module.letterListNavigation() = navigation( + metadata = ListDetailSceneStrategy.listPane(LetterListDestination::class), +) { route -> + val drawerState = LocalDrawerState.current + val navigator = LocalNavigator.current + + val coroutineScope = rememberCoroutineScope() + val viewModel: LetterListViewModel = koinViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Surface { + LetterListView( + modifier = Modifier.fillMaxSize(), + state = state, + onNavDrawerClicked = { + coroutineScope.launch { + drawerState.apply { + if (isClosed) open() else close() + } + } + }, + onScanClicked = { navigator.navigate(ScanLetterDestination()) }, + toggleCategory = viewModel::toggleCategory, + setSearchTerms = viewModel::setSearchTerms, + openLetter = { id, edit -> + if (edit) { + navigator.navigate(ScanLetterDestination(id)) + } else { + navigator.navigate(LetterDetailDestination(id)) + } + }, + onDeleteLetterClicked = viewModel::delete, + onReminderClicked = { id, edit -> + if (edit) { + navigator.navigate(ReminderDetailDestination(id)) + } else { + navigator.navigate(ReminderDetailDestination(id)) + } + }, + onCreateReminderClicked = { navigator.navigate(ReminderFormDestination(preselectedLetters = it)) }, + ) + } +} @Composable fun LetterListView( diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt index 66efd22..19b582b 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt @@ -98,28 +98,25 @@ class LetterListViewModel( } } - fun delete(id: LetterId) = - viewModelScope.launch { - deleteLetter(id) - load() - } + fun delete(id: LetterId) = viewModelScope.launch { + deleteLetter(id) + load() + } - fun toggleCategory(category: CategoryId?) = - viewModelScope.launch { - val toggleCategory = - if (category == state.selectedCategoryId) { - null - } else { - category - } + fun toggleCategory(category: CategoryId?) = viewModelScope.launch { + val toggleCategory = + if (category == state.selectedCategoryId) { + null + } else { + category + } - update { copy(selectedCategoryId = toggleCategory) } - load(categoryFilter = toggleCategory, searchTerms = state.searchTerms) - } + update { copy(selectedCategoryId = toggleCategory) } + load(categoryFilter = toggleCategory, searchTerms = state.searchTerms) + } - fun setSearchTerms(terms: String) = - viewModelScope.launch { - update { copy(searchTerms = terms) } - load(state.selectedCategoryId, searchTerms = terms) - } + fun setSearchTerms(terms: String) = viewModelScope.launch { + update { copy(searchTerms = terms) } + load(state.selectedCategoryId, searchTerms = terms) + } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterDestination.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterDestination.kt deleted file mode 100644 index 2db2dc7..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterDestination.kt +++ /dev/null @@ -1,47 +0,0 @@ -package net.frozendevelopment.openletters.feature.letter.scan - -import android.os.Bundle -import androidx.navigation.NavType -import kotlinx.serialization.Serializable -import net.frozendevelopment.openletters.data.sqldelight.models.LetterId -import kotlin.reflect.typeOf - -@Serializable -data class ScanLetterDestination( - val letterId: LetterId? = null, - val canNavigateBack: Boolean = true, -) { - companion object { - val typeMap = - mapOf( - typeOf() to NullableLetterIdNavType, - ) - } -} - -val NullableLetterIdNavType = - object : NavType(isNullableAllowed = true) { - override fun get( - bundle: Bundle, - key: String, - ): LetterId? = - bundle.getString(key).let { - if (it == null) null else LetterId(it) - } - - override fun parseValue(value: String): LetterId? { - if (value.isBlank()) return null - return LetterId(value) - } - - override fun serializeAsValue(value: LetterId?): String = value?.value ?: "" - - override fun put( - bundle: Bundle, - key: String, - value: LetterId?, - ) { - value ?: return - bundle.putString(key, value.value) - } - } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt index 3f365be..e795a32 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt @@ -1,6 +1,12 @@ package net.frozendevelopment.openletters.feature.letter.scan +import android.app.Activity +import android.app.Activity.RESULT_OK import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -11,8 +17,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -30,24 +38,155 @@ import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope 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.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.migrations.Category import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.DocumentId +import net.frozendevelopment.openletters.data.sqldelight.models.LetterId +import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination +import net.frozendevelopment.openletters.feature.letter.list.LetterListDestination import net.frozendevelopment.openletters.feature.letter.scan.ui.CategoryPicker import net.frozendevelopment.openletters.feature.letter.scan.ui.ScanAppBar import net.frozendevelopment.openletters.feature.letter.scan.ui.ScannableTextField import net.frozendevelopment.openletters.ui.components.BrokenImageView import net.frozendevelopment.openletters.ui.components.LazyImageView +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.core.parameter.parametersOf +import org.koin.dsl.navigation3.navigation import java.time.LocalDateTime +@Serializable +data class ScanLetterDestination( + val letterId: LetterId? = null, + val canNavigateBack: Boolean = true, +) : NavKey + +@OptIn(KoinExperimentalAPI::class) +fun Module.scanLetterNavigation() = navigation { route -> + val navigator = LocalNavigator.current + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val viewModel: ScanViewModel = koinViewModel { parametersOf(route.letterId) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val letterScanLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val scanResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) + viewModel.importScannedDocuments(scanResult) + } + } + + val senderScanLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val scanResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) + viewModel.importScannedSender(scanResult) + } + } + + val recipientScanLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val scanResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data) + viewModel.importScannedRecipient(scanResult) + } + } + + Surface { + ScanLetterView( + modifier = + Modifier + .statusBarsPadding() + .navigationBarsPadding(), + state = state, + canNavigateBack = route.canNavigateBack, + toggleCategory = viewModel::toggleCategory, + setSender = viewModel::setSender, + setRecipient = viewModel::setRecipient, + setTranscript = viewModel::setTranscript, + openLetterScanner = { + val activity = context as? Activity + if (activity != null) { + viewModel + .getScanner() + .getStartScanIntent(activity) + .addOnSuccessListener { intentSender -> + letterScanLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) + }.addOnFailureListener { + Log.e("ScanNavigation", "Scanner failed to load") + } + } + }, + openSenderScanner = { + val activity = context as? Activity + if (activity != null) { + viewModel + .getScanner(pageLimit = 1) + .getStartScanIntent(activity) + .addOnSuccessListener { intentSender -> + senderScanLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) + }.addOnFailureListener { + Log.e("ScanNavigation", "Scanner failed to load") + } + } + }, + openRecipientScanner = { + val activity = context as? Activity + if (activity != null) { + viewModel + .getScanner(pageLimit = 1) + .getStartScanIntent(activity) + .addOnSuccessListener { intentSender -> + recipientScanLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) + }.addOnFailureListener { + Log.e("ScanNavigation", "Scanner failed to load") + } + } + }, + onSaveClicked = { + coroutineScope.launch(Dispatchers.IO) { + if (viewModel.save()) { + withContext(Dispatchers.Main) { + if (route.canNavigateBack) { + navigator.onBackPressed() + } else { + navigator.navigate { backStack -> + backStack.add(0, LetterListDestination) + backStack.removeLastOrNull() + } + } + } + } + } + }, + onBackClicked = navigator::onBackPressed, + onDeleteDocumentClicked = viewModel::removeDocument, + onCreateCategoryClicked = { navigator.navigate(CategoryFormDestination(CategoryFormDestination.Mode.Create)) }, + ) + } +} + @Composable fun ScanLetterView( modifier: Modifier = Modifier, @@ -329,7 +468,10 @@ private fun FilledOutScanForm() { ScanState( sender = "", recipient = "", - transcript = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + transcript = + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + """.trimIndent(), newDocuments = mapOf( DocumentId.random() to Uri.EMPTY, diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt index a06331f..a341c7f 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt @@ -119,20 +119,19 @@ class ScanViewModel( } } - fun getScanner(pageLimit: Int = 0): GmsDocumentScanner = - GmsDocumentScanning.getClient( - GmsDocumentScannerOptions - .Builder() - .apply { - setGalleryImportAllowed(false) - setResultFormats(RESULT_FORMAT_JPEG) - setScannerMode(SCANNER_MODE_FULL) - - if (pageLimit > 0) { - setPageLimit(pageLimit) - } - }.build(), - ) + fun getScanner(pageLimit: Int = 0): GmsDocumentScanner = GmsDocumentScanning.getClient( + GmsDocumentScannerOptions + .Builder() + .apply { + setGalleryImportAllowed(false) + setResultFormats(RESULT_FORMAT_JPEG) + setScannerMode(SCANNER_MODE_FULL) + + if (pageLimit > 0) { + setPageLimit(pageLimit) + } + }.build(), + ) fun importScannedDocuments(scanResult: GmsDocumentScanningResult?) { viewModelScope.launch(ioDispatcher) { @@ -190,62 +189,57 @@ class ScanViewModel( } } - fun toggleCategory(category: Category) = - viewModelScope.launch { - val selectedCategories = state.selectedCategories.toMutableSet() - - if (selectedCategories.contains(category)) { - selectedCategories.remove(category) - } else { - selectedCategories.add(category) - } + fun toggleCategory(category: Category) = viewModelScope.launch { + val selectedCategories = state.selectedCategories.toMutableSet() - update { copy(selectedCategories = selectedCategories) } + if (selectedCategories.contains(category)) { + selectedCategories.remove(category) + } else { + selectedCategories.add(category) } - fun setSender(sender: String) = - viewModelScope.launch { - update { - copy( - sender = sender, - possibleSenders = searchSendersAndRecipients(sender), - ) - } - } + update { copy(selectedCategories = selectedCategories) } + } - fun setRecipient(recipient: String) = - viewModelScope.launch { - update { - copy( - recipient = recipient, - possibleRecipients = searchSendersAndRecipients(recipient), - ) - } + fun setSender(sender: String) = viewModelScope.launch { + update { + copy( + sender = sender, + possibleSenders = searchSendersAndRecipients(sender), + ) } + } - fun setTranscript(transcript: String) = - viewModelScope.launch { - update { copy(transcript = transcript.takeIf { it.isNotBlank() }) } + fun setRecipient(recipient: String) = viewModelScope.launch { + update { + copy( + recipient = recipient, + possibleRecipients = searchSendersAndRecipients(recipient), + ) } + } - fun removeDocument(documentId: DocumentId) = - viewModelScope.launch { - update { - copy( - newDocuments = - newDocuments - .toMutableMap() - .apply { remove(documentId) }, - existingDocuments = - existingDocuments - .toMutableMap() - .apply { remove(documentId) }, - ) - } + fun setTranscript(transcript: String) = viewModelScope.launch { + update { copy(transcript = transcript.takeIf { it.isNotBlank() }) } + } - rebuildTranscript() + fun removeDocument(documentId: DocumentId) = viewModelScope.launch { + update { + copy( + newDocuments = + newDocuments + .toMutableMap() + .apply { remove(documentId) }, + existingDocuments = + existingDocuments + .toMutableMap() + .apply { remove(documentId) }, + ) } + rebuildTranscript() + } + suspend fun save(): Boolean { update { copy(isBusy = true) } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderKoinModule.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderKoinModule.kt new file mode 100644 index 0000000..d4c3f84 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderKoinModule.kt @@ -0,0 +1,101 @@ +package net.frozendevelopment.openletters.feature.reminder + +import net.frozendevelopment.openletters.data.sqldelight.models.LetterId +import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId +import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailViewModel +import net.frozendevelopment.openletters.feature.reminder.detail.reminderDetailNavigation +import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormViewModel +import net.frozendevelopment.openletters.feature.reminder.form.reminderFormNavigation +import net.frozendevelopment.openletters.feature.reminder.list.ReminderListViewModel +import net.frozendevelopment.openletters.feature.reminder.list.reminderListNavigation +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module +// import org.koin.android.annotation.KoinViewModel +// import org.koin.core.annotation.InjectedParam +// import org.koin.core.annotation.Module + +// @Module +// class ReminderKoinModule { +// // @KoinViewModel +// fun reminderListViewModel( +// application: Application, +// reminderQueries: ReminderQueries, +// deleteReminder: DeleteReminderUseCase, +// ) = ReminderListViewModel( +// application = application, +// reminderQueries = reminderQueries, +// deleteReminder = deleteReminder, +// ) +// +// // @KoinViewModel +// fun reminderFormViewModel( +// @InjectedParam reminderToEdit: ReminderId?, +// @InjectedParam preselectedLetters: List, +// application: Application, +// searchLettersUseCase: SearchLettersUseCase, +// upsertReminderUseCase: UpsertReminderUseCase, +// reminderQueries: ReminderQueries, +// ) = ReminderFormViewModel( +// reminderToEdit = reminderToEdit, +// application = application, +// searchLetters = searchLettersUseCase, +// createReminder = upsertReminderUseCase, +// reminderQueries = reminderQueries, +// preselectedLetters = preselectedLetters, +// ) +// +// // @KoinViewModel +// fun reminderDetailViewModel( +// @InjectedParam reminderId: ReminderId, +// application: Application, +// reminderWithDetailsUseCase: ReminderWithDetailsUseCase, +// acknowledgeReminderUseCase: AcknowledgeReminderUseCase, +// ) = ReminderDetailViewModel( +// reminderId = reminderId, +// application = application, +// reminderWithDetails = reminderWithDetailsUseCase, +// acknowledgeReminder = acknowledgeReminderUseCase, +// ) +// +// companion object { +// val navigationModule = module { +// reminderDetailNavigation() +// reminderFormNavigation() +// reminderListNavigation() +// } +// } +// } + +val reminderKoinModule = + module { + reminderDetailNavigation() + reminderFormNavigation() + reminderListNavigation() + viewModel { + ReminderListViewModel( + application = get(), + reminderQueries = get(), + deleteReminder = get(), + ) + } + + viewModel { (reminderToEdit: ReminderId?, preselectedLetters: List) -> + ReminderFormViewModel( + reminderToEdit = reminderToEdit, + application = get(), + searchLetters = get(), + createReminder = get(), + reminderQueries = get(), + preselectedLetters = preselectedLetters, + ) + } + + viewModel { (reminderId: ReminderId) -> + ReminderDetailViewModel( + reminderId = reminderId, + application = get(), + reminderWithDetails = get(), + acknowledgeReminder = get(), + ) + } + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderNavigation.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderNavigation.kt deleted file mode 100644 index 3c53c54..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderNavigation.kt +++ /dev/null @@ -1,154 +0,0 @@ -package net.frozendevelopment.openletters.feature.reminder - -import android.Manifest -import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.DrawerState -import androidx.compose.material3.Surface -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import kotlinx.coroutines.launch -import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailDestination -import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination -import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailScreen -import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailState -import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailViewModel -import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination -import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormView -import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormViewModel -import net.frozendevelopment.openletters.feature.reminder.list.ReminderListDestination -import net.frozendevelopment.openletters.feature.reminder.list.ReminderListView -import net.frozendevelopment.openletters.feature.reminder.list.ReminderListViewModel -import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf - -fun NavGraphBuilder.reminders( - navController: NavController, - drawerState: DrawerState, -) { - composable { - val coroutineScope = rememberCoroutineScope() - val viewModel = koinViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - val notificationPermissionResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = viewModel::handlePermissionResult, - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !state.hasNotificationPermission) { - LaunchedEffect(Unit) { - notificationPermissionResultLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - - Surface { - ReminderListView( - modifier = Modifier.fillMaxSize(), - state = state, - openNavigationDrawer = { - coroutineScope.launch { - drawerState.apply { - if (isClosed) open() else close() - } - } - }, - onReminderClicked = { id, edit -> - if (edit) { - navController.navigate(ReminderFormDestination(id)) - } else { - navController.navigate(ReminderDetailDestination(id)) - } - }, - createReminderClicked = { navController.navigate(ReminderFormDestination()) }, - onDeleteReminderClicked = viewModel::delete, - ) - } - } - composable( - typeMap = ReminderDetailDestination.typeMap, - deepLinks = ReminderDetailDestination.deepLinks, - ) { backStackEntry -> - val destination = backStackEntry.toRoute() - val viewModel = koinViewModel { parametersOf(destination.reminderId) } - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - val notificationPermissionResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = viewModel::handlePermissionResult, - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (state as? ReminderDetailState.Detail)?.hasNotificationPermission == false) { - LaunchedEffect(Unit) { - notificationPermissionResultLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - - Surface { - ReminderDetailScreen( - modifier = Modifier.fillMaxWidth(), - state = state, - onBackClicked = navController::navigateUp, - onAcknowledgeClicked = viewModel::acknowledge, - onLetterClicked = { navController.navigate(LetterDetailDestination(it)) }, - ) - } - } - composable( - typeMap = ReminderFormDestination.typeMap, - ) { backStackEntry -> - val destination = backStackEntry.toRoute() - val coroutineScope = rememberCoroutineScope() - val viewModel = - koinViewModel { - parametersOf(destination.reminderId, destination.preselectedLetters) - } - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - val notificationPermissionResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = viewModel::handlePermissionResult, - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !state.hasNotificationPermission) { - LaunchedEffect(Unit) { - notificationPermissionResultLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - - Surface { - ReminderFormView( - modifier = Modifier.fillMaxSize(), - state = state, - onTitleChanged = viewModel::setTitle, - onDescriptionChanged = viewModel::setDescription, - onDateSelected = viewModel::selectDate, - onTimeSelected = viewModel::selectTime, - toggleLetterSelect = viewModel::toggleLetterSelect, - onLetterClicked = { navController.navigate(LetterDetailDestination(it)) }, - openDialog = viewModel::openDialog, - onBackClicked = navController::navigateUp, - onSaveClicked = { - coroutineScope.launch { - if (viewModel.save()) { - navController.navigateUp() - } - } - }, - ) - } - } -} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailDestination.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailDestination.kt deleted file mode 100644 index 2d857f0..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailDestination.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.frozendevelopment.openletters.feature.reminder.detail - -import android.os.Bundle -import androidx.navigation.NavType -import androidx.navigation.navDeepLink -import kotlinx.serialization.Serializable -import net.frozendevelopment.openletters.DEEP_LINK_URI -import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId -import kotlin.reflect.typeOf - -@Serializable -data class ReminderDetailDestination( - val reminderId: ReminderId, -) { - companion object { - val typeMap = mapOf(typeOf() to ReminderIdNavType) - val deepLinks = - listOf( - navDeepLink( - basePath = "$DEEP_LINK_URI/reminder", - typeMap = typeMap, - ), - ) - } -} - -val ReminderIdNavType = - object : NavType(isNullableAllowed = false) { - override fun get( - bundle: Bundle, - key: String, - ): ReminderId? = - bundle.getString(key).let { - if (it == null) null else ReminderId(it) - } - - override fun parseValue(value: String): ReminderId = ReminderId(value) - - override fun serializeAsValue(value: ReminderId): String = value.value - - override fun put( - bundle: Bundle, - key: String, - value: ReminderId, - ) { - bundle.putString(key, value.value) - } - } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailView.kt index 83d5879..e11556a 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailView.kt @@ -1,6 +1,10 @@ package net.frozendevelopment.openletters.feature.reminder.detail +import android.Manifest import android.app.Activity +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -24,7 +28,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,11 +46,64 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import net.frozendevelopment.openletters.DEEP_LINK_URI import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.models.LetterId +import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId import net.frozendevelopment.openletters.extensions.dateTimeString import net.frozendevelopment.openletters.extensions.openAppSettings +import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailDestination +import net.frozendevelopment.openletters.feature.reminder.list.ReminderListDestination import net.frozendevelopment.openletters.ui.components.LetterCell +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.core.parameter.parametersOf +import org.koin.dsl.navigation3.navigation + +@Serializable +data class ReminderDetailDestination( + val reminderId: ReminderId, +) : NavKey { + companion object { + const val DEEP_LINK_PATTERN = "$DEEP_LINK_URI/reminder/{reminderId}" + } +} + +@OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) +fun Module.reminderDetailNavigation() = navigation( + metadata = ListDetailSceneStrategy.detailPane(ReminderListDestination::class), +) { route -> + val navigator = LocalNavigator.current + val viewModel = koinViewModel { parametersOf(route.reminderId) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val notificationPermissionResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = viewModel::handlePermissionResult, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (state as? ReminderDetailState.Detail)?.hasNotificationPermission == false) { + LaunchedEffect(Unit) { + notificationPermissionResultLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + Surface { + ReminderDetailScreen( + modifier = Modifier.fillMaxWidth(), + state = state, + onBackClicked = navigator::onBackPressed, + onAcknowledgeClicked = viewModel::acknowledge, + onLetterClicked = { navigator.navigate(LetterDetailDestination(it)) }, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailViewModel.kt index 8c6820d..b9a8292 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailViewModel.kt @@ -80,15 +80,14 @@ class ReminderDetailViewModel( } } - fun acknowledge() = - viewModelScope.launch { - acknowledgeReminder(reminderId) - update { - if (this is ReminderDetailState.Detail) { - copy(isAcknowledged = true) - } else { - this - } + fun acknowledge() = viewModelScope.launch { + acknowledgeReminder(reminderId) + update { + if (this is ReminderDetailState.Detail) { + copy(isAcknowledged = true) + } else { + this } } + } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormDestination.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormDestination.kt deleted file mode 100644 index 2d870b5..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormDestination.kt +++ /dev/null @@ -1,71 +0,0 @@ -package net.frozendevelopment.openletters.feature.reminder.form - -import android.os.Bundle -import androidx.navigation.NavType -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import net.frozendevelopment.openletters.data.sqldelight.models.LetterId -import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId -import kotlin.reflect.typeOf - -@Serializable -data class ReminderFormDestination( - val reminderId: ReminderId? = null, - val preselectedLetters: List = emptyList(), -) { - companion object { - val typeMap = - mapOf( - typeOf() to NullableReminderIdNavType, - typeOf>() to LetterListNavType, - ) - } -} - -val NullableReminderIdNavType = - object : NavType(isNullableAllowed = true) { - override fun get( - bundle: Bundle, - key: String, - ): ReminderId? = - bundle.getString(key).let { - if (it == null) null else ReminderId(it) - } - - override fun parseValue(value: String): ReminderId? { - if (value.isBlank()) return null - return ReminderId(value) - } - - override fun serializeAsValue(value: ReminderId?): String = value?.value ?: "" - - override fun put( - bundle: Bundle, - key: String, - value: ReminderId?, - ) { - value ?: return - bundle.putString(key, value.value) - } - } - -val LetterListNavType = - object : NavType>(isNullableAllowed = false) { - override fun get( - bundle: Bundle, - key: String, - ): List = bundle.getStringArrayList(key)?.map { LetterId(it) } ?: emptyList() - - override fun parseValue(value: String): List = Json.decodeFromString(value) - - override fun serializeAsValue(value: List): String = Json.encodeToString(value) - - override fun put( - bundle: Bundle, - key: String, - value: List, - ) { - bundle.putStringArrayList(key, ArrayList(value.map { it.value })) - } - } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt index 011c618..0c53ba9 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt @@ -1,6 +1,10 @@ package net.frozendevelopment.openletters.feature.reminder.form +import android.Manifest import android.app.Activity +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -35,6 +39,9 @@ import androidx.compose.material3.TimeInput import androidx.compose.material3.TimePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged @@ -44,17 +51,80 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.models.LetterId +import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId import net.frozendevelopment.openletters.extensions.openAppSettings +import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailDestination import net.frozendevelopment.openletters.ui.components.FormAppBar import net.frozendevelopment.openletters.ui.components.LetterCell import net.frozendevelopment.openletters.ui.components.SelectCell +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.core.parameter.parametersOf +import org.koin.dsl.navigation3.navigation import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +@Serializable +data class ReminderFormDestination( + val reminderId: ReminderId? = null, + val preselectedLetters: List = emptyList(), +) : NavKey + +@OptIn(KoinExperimentalAPI::class) +fun Module.reminderFormNavigation() = navigation { route -> + val navigator = LocalNavigator.current + val coroutineScope = rememberCoroutineScope() + val viewModel = + koinViewModel { + parametersOf(route.reminderId, route.preselectedLetters) + } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val notificationPermissionResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = viewModel::handlePermissionResult, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !state.hasNotificationPermission) { + LaunchedEffect(Unit) { + notificationPermissionResultLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + Surface { + ReminderFormView( + modifier = Modifier.fillMaxSize(), + state = state, + onTitleChanged = viewModel::setTitle, + onDescriptionChanged = viewModel::setDescription, + onDateSelected = viewModel::selectDate, + onTimeSelected = viewModel::selectTime, + toggleLetterSelect = viewModel::toggleLetterSelect, + onLetterClicked = { navigator.navigate(LetterDetailDestination(it)) }, + openDialog = viewModel::openDialog, + onBackClicked = navigator::onBackPressed, + onSaveClicked = { + coroutineScope.launch { + if (viewModel.save()) { + navigator.onBackPressed() + } + } + }, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReminderFormView( diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt index 7000e2c..11f17d1 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt @@ -178,61 +178,57 @@ class ReminderFormViewModel( update { copy(hasNotificationPermission = granted) } } - fun setTitle(title: String) = - viewModelScope.launch { - update { - copy( - title = title, - titleError = if (title.length > 15 || title.isBlank()) "Title must be between 1 and 25 characters" else "", - ) - } + fun setTitle(title: String) = viewModelScope.launch { + update { + copy( + title = title, + titleError = if (title.length > 15 || title.isBlank()) "Title must be between 1 and 25 characters" else "", + ) } + } - fun setDescription(description: String) = - viewModelScope.launch { - update { copy(description = description) } - } + fun setDescription(description: String) = viewModelScope.launch { + update { copy(description = description) } + } - fun toggleLetterSelect(letterId: LetterId) = - viewModelScope.launch { - val letterSet = - state.selectedLetters.toMutableSet().let { - if (state.selectedLetters.contains(letterId)) { - it - letterId - } else { - it + letterId - } + fun toggleLetterSelect(letterId: LetterId) = viewModelScope.launch { + val letterSet = + state.selectedLetters.toMutableSet().let { + if (state.selectedLetters.contains(letterId)) { + it - letterId + } else { + it + letterId } + } - update { copy(selectedLetters = letterSet.toList()) } - } + update { copy(selectedLetters = letterSet.toList()) } + } - fun selectDate(dateInMillis: Long) = - viewModelScope.launch { - val instant = Instant.ofEpochMilli(dateInMillis) - - val dateUTC = - LocalDateTime - .ofInstant(instant, ZoneId.of("UTC")) - .withHour(state.selectedDate.hour) - .withMinute(state.selectedDate.minute) - - val dateLocal = - LocalDateTime.of( - dateUTC.year, - dateUTC.monthValue, - dateUTC.dayOfMonth, - dateUTC.hour, - dateUTC.minute, - ) + fun selectDate(dateInMillis: Long) = viewModelScope.launch { + val instant = Instant.ofEpochMilli(dateInMillis) + + val dateUTC = + LocalDateTime + .ofInstant(instant, ZoneId.of("UTC")) + .withHour(state.selectedDate.hour) + .withMinute(state.selectedDate.minute) + + val dateLocal = + LocalDateTime.of( + dateUTC.year, + dateUTC.monthValue, + dateUTC.dayOfMonth, + dateUTC.hour, + dateUTC.minute, + ) - update { - copy( - selectedDate = dateLocal, - dateError = if (!dateLocal.isAfter(LocalDateTime.now())) "Date must be in the future" else "", - ) - } + update { + copy( + selectedDate = dateLocal, + dateError = if (!dateLocal.isAfter(LocalDateTime.now())) "Date must be in the future" else "", + ) } + } fun selectTime( hour: Int, @@ -251,10 +247,9 @@ class ReminderFormViewModel( } } - fun openDialog(dialog: ReminderFormState.Dialog?) = - viewModelScope.launch { - update { copy(shownDialog = dialog) } - } + fun openDialog(dialog: ReminderFormState.Dialog?) = viewModelScope.launch { + update { copy(shownDialog = dialog) } + } fun save(): Boolean { if (!state.isSavable) return false diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt index b423fdb..3c6f4fb 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt @@ -1,6 +1,13 @@ package net.frozendevelopment.openletters.feature.reminder.list +import android.Manifest import android.app.Activity +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -21,26 +28,94 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.NavDisplay +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId import net.frozendevelopment.openletters.extensions.openAppSettings +import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination +import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination import net.frozendevelopment.openletters.feature.reminder.list.ui.EmptyReminderListCell import net.frozendevelopment.openletters.ui.components.ActionReminderCell import net.frozendevelopment.openletters.ui.components.ReminderPeekMenu +import net.frozendevelopment.openletters.ui.navigation.LocalDrawerState +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.dsl.navigation3.navigation @Serializable -object ReminderListDestination +object ReminderListDestination : NavKey + +@OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) +fun Module.reminderListNavigation() = navigation( + metadata = NavDisplay.transitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.popTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.predictivePopTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + ListDetailSceneStrategy.listPane(ReminderListDestination::class), +) { route -> + val navigator = LocalNavigator.current + val drawerState = LocalDrawerState.current + val coroutineScope = rememberCoroutineScope() + val viewModel = koinViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val notificationPermissionResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = viewModel::handlePermissionResult, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !state.hasNotificationPermission) { + LaunchedEffect(Unit) { + notificationPermissionResultLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + Surface { + ReminderListView( + modifier = Modifier.fillMaxSize(), + state = state, + openNavigationDrawer = { + coroutineScope.launch { + drawerState.apply { + if (isClosed) open() else close() + } + } + }, + onReminderClicked = { id, edit -> + if (edit) { + navigator.navigate(ReminderFormDestination(id)) + } else { + navigator.navigate(ReminderDetailDestination(id)) + } + }, + createReminderClicked = { navigator.navigate(ReminderFormDestination()) }, + onDeleteReminderClicked = viewModel::delete, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsKoinModule.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsKoinModule.kt new file mode 100644 index 0000000..5fac1c8 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsKoinModule.kt @@ -0,0 +1,33 @@ +package net.frozendevelopment.openletters.feature.settings + +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module +// import org.koin.android.annotation.KoinViewModel +// import org.koin.core.annotation.Module + +// // @Module +// class SettingsKoinModule { +// // @KoinViewModel +// fun settingsViewModel( +// application: Application, +// themeManager: ThemeManagerType, +// ) = SettingsViewModel( +// themeManager = themeManager, +// ) +// +// companion object { +// val navigationModule = module { +// settingsNavigation() +// } +// } +// } + +val settingsKoinModule = + module { + settingsNavigation() + viewModel { + SettingsViewModel( + themeManager = get(), + ) + } + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsNavigation.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsNavigation.kt deleted file mode 100644 index faeffba..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsNavigation.kt +++ /dev/null @@ -1,34 +0,0 @@ -package net.frozendevelopment.openletters.feature.settings - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Surface -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import kotlinx.serialization.Serializable -import net.frozendevelopment.openletters.extensions.openUrl -import org.koin.androidx.compose.koinViewModel - -@Serializable -data object SettingsDestination - -fun NavGraphBuilder.settings(navController: NavController) { - composable { - val viewModel: SettingsViewModel = koinViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - Surface { - SettingsView( - modifier = Modifier.fillMaxSize(), - state = state, - onBackClicked = { navController.popBackStack() }, - onThemeChanged = viewModel::setTheme, - onColorVariantChanged = viewModel::setVariant, - onViewSourceClicked = { navController.openUrl("https://github.com/frozenjava/OpenLetters") }, - ) - } - } -} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsView.kt index 4517e1e..06e8299 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsView.kt @@ -25,12 +25,41 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.feature.settings.ui.DropDownButton import net.frozendevelopment.openletters.ui.components.VersionStamp +import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.AppTheme import net.frozendevelopment.openletters.ui.theme.ColorPalette import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.module.Module +import org.koin.dsl.navigation3.navigation + +@Serializable +data object SettingsDestination : NavKey + +@OptIn(KoinExperimentalAPI::class) +fun Module.settingsNavigation() = navigation { route -> + val navigator = LocalNavigator.current + val viewModel: SettingsViewModel = koinViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + Surface { + SettingsView( + modifier = Modifier.fillMaxSize(), + state = state, + onBackClicked = navigator::onBackPressed, + onThemeChanged = viewModel::setTheme, + onColorVariantChanged = viewModel::setVariant, + onViewSourceClicked = { navigator.openUrl("https://github.com/frozenjava/OpenLetters") }, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsViewModel.kt index 3c08ebe..6a97b8a 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsViewModel.kt @@ -31,15 +31,13 @@ class SettingsViewModel( } } - fun setTheme(theme: AppTheme) = - viewModelScope.launch { - themeManager.setTheme(theme) - update { copy(appTheme = theme) } - } + fun setTheme(theme: AppTheme) = viewModelScope.launch { + themeManager.setTheme(theme) + update { copy(appTheme = theme) } + } - fun setVariant(variant: ColorPalette) = - viewModelScope.launch { - themeManager.setVariant(variant) - update { copy(themeVariant = variant) } - } + fun setVariant(variant: ColorPalette) = viewModelScope.launch { + themeManager.setVariant(variant) + update { copy(themeVariant = variant) } + } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt b/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt index db4ff37..7aef7d5 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt @@ -1,22 +1,31 @@ package net.frozendevelopment.openletters.migration -import net.frozendevelopment.openletters.data.sqldelight.AppMigrationQueries -import net.frozendevelopment.openletters.data.sqldelight.CategoryQueries -import org.koin.core.annotation.Factory -import org.koin.core.annotation.Module +// import org.koin.core.annotation.Factory +// import org.koin.core.annotation.Module +import org.koin.dsl.module -@Module -class AppMigrationKoin { - @Factory - fun appMigrator( - appMigrationQueries: AppMigrationQueries, - categoryQueries: CategoryQueries, - ): AppMigrator = - AppMigrator( - appMigrationQueries = appMigrationQueries, - migrations = - listOf( - InitialCategoriesMigration(categoryQueries), - ), - ) -} +// // @Module +// class AppMigrationKoin { +// // @Factory +// fun appMigrator( +// appMigrationQueries: AppMigrationQueries, +// categoryQueries: CategoryQueries, +// ): AppMigrator = +// AppMigrator( +// appMigrationQueries = appMigrationQueries, +// migrations = +// listOf( +// InitialCategoriesMigration(categoryQueries), +// ), +// ) +// } + +val appMigrationKoinModule = + module { + factory { + AppMigrator( + appMigrationQueries = get(), + migrations = listOf(InitialCategoriesMigration(get())), + ) + } + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrator.kt b/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrator.kt index 857af2d..6980419 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrator.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrator.kt @@ -10,7 +10,7 @@ interface AppMigration { class AppMigrator( private val migrations: List, - private val appMigrationQueries: net.frozendevelopment.openletters.data.sqldelight.AppMigrationQueries, + private val appMigrationQueries: AppMigrationQueries, ) { fun migrate() { for (migration in migrations) { diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/animation/NavigationTransitions.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/animation/NavigationTransitions.kt index 2ded871..730bfa4 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/animation/NavigationTransitions.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/animation/NavigationTransitions.kt @@ -1,37 +1,60 @@ package net.frozendevelopment.openletters.ui.animation import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.navigation.NavBackStackEntry +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene -private const val DURATION = 100 +private const val DURATION = 250 -fun AnimatedContentTransitionScope.navigationEnterTransition(): EnterTransition = - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, +val pushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = { + val incoming = slideInHorizontally( + animationSpec = tween(DURATION), + ) { fullWidth -> fullWidth } + val outgoing = scaleOut( + targetScale = 0.95f, + animationSpec = tween(DURATION), + ) + fadeOut( + targetAlpha = 0.5f, animationSpec = tween(DURATION), ) -fun AnimatedContentTransitionScope.navigationExitTransition(): ExitTransition = - scaleOut( - targetScale = .95f, - animationSpec = tween(DURATION), - ) + fadeOut(targetAlpha = .5f, animationSpec = tween(DURATION)) + ContentTransform( + targetContentEnter = incoming, + initialContentExit = outgoing, + sizeTransform = null, + ) +} -fun AnimatedContentTransitionScope.navigationPopEnterTransition(): EnterTransition = - scaleIn( - initialScale = .95f, +val popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = { + val outgoing = slideOutHorizontally( + animationSpec = tween(DURATION), + ) { fullWidth -> fullWidth } + scaleOut( + targetScale = 1.05f, + animationSpec = tween(DURATION), + ) + fadeOut( + targetAlpha = 0.5f, animationSpec = tween(DURATION), - ) + fadeIn(initialAlpha = .5f, animationSpec = tween(DURATION)) + ) -fun AnimatedContentTransitionScope.navigationPopExitTransition(): ExitTransition = - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.End, + val incoming = scaleIn( + initialScale = 0.95f, + animationSpec = tween(DURATION), + ) + fadeIn( + initialAlpha = 0.5f, animationSpec = tween(DURATION), - ) + scaleOut(targetScale = 1.05f, animationSpec = tween(DURATION)) + ) + + ContentTransform( + targetContentEnter = incoming, + initialContentExit = outgoing, + sizeTransform = null, + ) +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/components/LettersNavDrawer.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/LettersNavDrawer.kt similarity index 83% rename from app/src/main/java/net/frozendevelopment/openletters/ui/components/LettersNavDrawer.kt rename to app/src/main/java/net/frozendevelopment/openletters/ui/navigation/LettersNavDrawer.kt index 971ad53..5ad5319 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/components/LettersNavDrawer.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/LettersNavDrawer.kt @@ -1,4 +1,4 @@ -package net.frozendevelopment.openletters.ui.components +package net.frozendevelopment.openletters.ui.navigation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -6,26 +6,27 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import net.frozendevelopment.openletters.R -import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme +import net.frozendevelopment.openletters.ui.components.VersionStamp +import net.frozendevelopment.openletters.ui.preview.PreviewContainer + +val LocalDrawerState = compositionLocalOf { error("NavigationState not provided") } @Composable fun LettersNavDrawer( - drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + drawerState: DrawerState = LocalDrawerState.current, goToMail: () -> Unit, goToManageCategories: () -> Unit, goToCreateCategory: () -> Unit, @@ -129,19 +130,17 @@ private fun DrawerContent( } } -@Preview +@PreviewLightDark @Composable fun MailNavDrawerPreview() { - OpenLettersTheme { - Surface { - DrawerContent( - goToMail = {}, - goToManageCategories = {}, - goToCreateCategory = {}, - goToReminders = {}, - goToCreateReminder = {}, - goToSettings = {}, - ) - } + PreviewContainer { + DrawerContent( + goToMail = {}, + goToManageCategories = {}, + goToCreateCategory = {}, + goToReminders = {}, + goToCreateReminder = {}, + goToSettings = {}, + ) } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavigationState.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavigationState.kt new file mode 100644 index 0000000..f21bbfe --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavigationState.kt @@ -0,0 +1,84 @@ +package net.frozendevelopment.openletters.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer + +val LocalNavigationState = compositionLocalOf { error("NavigationState not provided") } + +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set, +): NavigationState { + val topLevelRoute = + rememberSerializable( + startRoute, + topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()), + ) { + mutableStateOf(startRoute) + } + + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + + return remember(startRoute, topLevelRoute) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks.toMutableMap(), + ) + } +} + +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: MutableMap>, +) { + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = + if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } +} + +@Composable +fun NavigationState.toEntries(entryProvider: (NavKey) -> NavEntry): SnapshotStateList> { + val decoratedEntries = + backStacks.mapValues { (_, stack) -> + val decorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider, + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt new file mode 100644 index 0000000..5c4fdc7 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt @@ -0,0 +1,73 @@ +package net.frozendevelopment.openletters.ui.navigation + +import androidx.activity.OnBackPressedDispatcher +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey + +val LocalNavigator = compositionLocalOf { error("Navigator not provided") } + +interface NavigatorType { + fun navigate(block: (NavBackStack) -> Unit) + + fun navigate(route: NavKey) + + fun pop() + + fun onBackPressed() + + fun openUrl(url: String) +} + +@Stable +class Navigator( + val state: NavigationState, + val backPressedDispatcher: OnBackPressedDispatcher? = null, + val openInBrowser: (url: String) -> Unit = {}, +) : NavigatorType { + override fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + if (route == state.topLevelRoute) { + state.backStacks[route]!!.removeAll { it != route } + } else { + state.topLevelRoute = route + } + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + + override fun navigate(block: (NavBackStack) -> Unit) { + block(state.backStacks[state.topLevelRoute] ?: error("No back stack for current route")) + } + + override fun pop() { + val currentStack = state.backStacks[state.topLevelRoute] ?: error("No back stack for current route") + val currentRoute = currentStack.last() + + if (currentRoute == state.topLevelRoute) { + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() + } + } + + override fun onBackPressed() { + backPressedDispatcher?.onBackPressed() + } + + override fun openUrl(url: String) = openInBrowser(url) +} + +class PreviewNavigator : NavigatorType { + override fun navigate(block: (NavBackStack) -> Unit) {} + + override fun navigate(route: NavKey) {} + + override fun pop() {} + + override fun onBackPressed() {} + + override fun openUrl(url: String) {} +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/preview/PreviewContainer.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/preview/PreviewContainer.kt new file mode 100644 index 0000000..089d60a --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/preview/PreviewContainer.kt @@ -0,0 +1,24 @@ +package net.frozendevelopment.openletters.ui.preview + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme + +@Composable +@VisibleForTesting +fun PreviewContainer( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + OpenLettersTheme( + darkTheme = darkTheme, + dynamicColor = dynamicColor, + ) { + Surface { + content() + } + } +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/usecase/SearchLettersUseCase.kt b/app/src/main/java/net/frozendevelopment/openletters/usecase/SearchLettersUseCase.kt index c14ea63..98d768d 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/usecase/SearchLettersUseCase.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/usecase/SearchLettersUseCase.kt @@ -13,25 +13,24 @@ class SearchLettersUseCase( query: String, category: CategoryId? = null, limit: Long = Long.MAX_VALUE, - ): List = - if (query.isBlank()) { + ): List = if (query.isBlank()) { + letterQueries + .letterList( + categoryId = category, + limit = limit, + ).executeAsList() + } else { + try { letterQueries - .letterList( + .search( categoryId = category, + query = "${query.sanitizeForSearch()}*", limit = limit, ).executeAsList() - } else { - try { - letterQueries - .search( - categoryId = category, - query = "${query.sanitizeForSearch()}*", - limit = limit, - ).executeAsList() - } catch (e: SQLiteException) { - // a user can crash the app by constructing a query that FTS doesnt like - // for example `*bob` will crash the app. An empty list is better than a crash - emptyList() - } + } catch (e: SQLiteException) { + // a user can crash the app by constructing a query that FTS doesnt like + // for example `*bob` will crash the app. An empty list is better than a crash + emptyList() } + } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/usecase/UseCaseKoin.kt b/app/src/main/java/net/frozendevelopment/openletters/usecase/UseCaseKoin.kt index e96a9f8..515e88e 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/usecase/UseCaseKoin.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/usecase/UseCaseKoin.kt @@ -1,75 +1,86 @@ package net.frozendevelopment.openletters.usecase -import android.content.Context -import net.frozendevelopment.openletters.data.sqldelight.CategoryQueries -import net.frozendevelopment.openletters.data.sqldelight.DocumentQueries -import net.frozendevelopment.openletters.data.sqldelight.LetterQueries -import net.frozendevelopment.openletters.data.sqldelight.OpenLettersDB -import net.frozendevelopment.openletters.data.sqldelight.ReminderQueries import net.frozendevelopment.openletters.feature.reminder.notification.ReminderNotification import net.frozendevelopment.openletters.feature.reminder.notification.ReminderNotificationType -import net.frozendevelopment.openletters.util.DocumentManagerType -import org.koin.core.annotation.Factory -import org.koin.core.annotation.Module +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +// import org.koin.core.annotation.Factory +// import org.koin.core.annotation.Module -@Module -class UseCaseKoin { - @Factory - fun createLetterUseCase( - documentManager: DocumentManagerType, - database: OpenLettersDB, - ): UpsertLetterUseCase = UpsertLetterUseCase(documentManager, database) +// // @Module +// class UseCaseKoin { +// // @Factory +// fun createLetterUseCase( +// documentManager: DocumentManagerType, +// database: OpenLettersDB, +// ): UpsertLetterUseCase = UpsertLetterUseCase(documentManager, database) +// +// // @Factory +// fun metaLetterUseCase(letterQueries: LetterQueries): LetterCellUseCase = LetterCellUseCase(queries = letterQueries) +// +// // @Factory +// fun upsertCategoryUseCase(categoryQueries: CategoryQueries) = UpsertCategoryUseCase(categoryQueries) +// +// // @Factory +// fun letterWithDetailsUseCase( +// documentManager: DocumentManagerType, +// database: OpenLettersDB, +// ) = LetterWithDetailsUseCase(documentManager, database) +// +// // @Factory +// fun searchLettersUseCase(letterQueries: LetterQueries) = SearchLettersUseCase(letterQueries) +// +// // @Factory +// fun reminderNotification(context: Context): ReminderNotificationType = ReminderNotification(context) +// +// // @Factory +// fun createReminderUseCase( +// reminderQueries: ReminderQueries, +// reminderNotification: ReminderNotificationType, +// ) = UpsertReminderUseCase(reminderQueries, reminderNotification) +// +// // @Factory +// fun reminderWithDetailsUseCase(reminderQueries: ReminderQueries) = ReminderWithDetailsUseCase(reminderQueries) +// +// // @Factory +// fun acknowledgeReminderUseCase( +// reminderQueries: ReminderQueries, +// reminderNotification: ReminderNotificationType, +// ) = AcknowledgeReminderUseCase(reminderQueries, reminderNotification) +// +// // @Factory +// fun deleteReminderUseCase( +// reminderQueries: ReminderQueries, +// reminderNotification: ReminderNotificationType, +// ) = DeleteReminderUseCase(reminderQueries, reminderNotification) +// +// // @Factory +// fun deleteLetterUseCase( +// letterQueries: LetterQueries, +// documentQueries: DocumentQueries, +// documentManager: DocumentManagerType, +// ) = DeleteLetterUseCase( +// letterQueries = letterQueries, +// documentQueries = documentQueries, +// documentManager = documentManager, +// ) +// +// // @Factory +// fun saveCategoryOrderUseCase(categoryQueries: CategoryQueries) = SaveCategoryOrderUseCase(categoryQueries) +// } - @Factory - fun metaLetterUseCase(letterQueries: LetterQueries): LetterCellUseCase = LetterCellUseCase(queries = letterQueries) - - @Factory - fun upsertCategoryUseCase(categoryQueries: CategoryQueries) = UpsertCategoryUseCase(categoryQueries) - - @Factory - fun letterWithDetailsUseCase( - documentManager: DocumentManagerType, - database: OpenLettersDB, - ) = LetterWithDetailsUseCase(documentManager, database) - - @Factory - fun searchLettersUseCase(letterQueries: LetterQueries) = SearchLettersUseCase(letterQueries) - - @Factory - fun reminderNotification(context: Context): ReminderNotificationType = ReminderNotification(context) - - @Factory - fun createReminderUseCase( - reminderQueries: ReminderQueries, - reminderNotification: ReminderNotificationType, - ) = UpsertReminderUseCase(reminderQueries, reminderNotification) - - @Factory - fun reminderWithDetailsUseCase(reminderQueries: ReminderQueries) = ReminderWithDetailsUseCase(reminderQueries) - - @Factory - fun acknowledgeReminderUseCase( - reminderQueries: ReminderQueries, - reminderNotification: ReminderNotificationType, - ) = AcknowledgeReminderUseCase(reminderQueries, reminderNotification) - - @Factory - fun deleteReminderUseCase( - reminderQueries: ReminderQueries, - reminderNotification: ReminderNotificationType, - ) = DeleteReminderUseCase(reminderQueries, reminderNotification) - - @Factory - fun deleteLetterUseCase( - letterQueries: LetterQueries, - documentQueries: DocumentQueries, - documentManager: DocumentManagerType, - ) = DeleteLetterUseCase( - letterQueries = letterQueries, - documentQueries = documentQueries, - documentManager = documentManager, - ) - - @Factory - fun saveCategoryOrderUseCase(categoryQueries: CategoryQueries) = SaveCategoryOrderUseCase(categoryQueries) -} +val useCaseKoinModule = + module { + factory { UpsertLetterUseCase(get(), get()) } + factory { LetterCellUseCase(get()) } + factory { UpsertCategoryUseCase(get()) } + factory { LetterWithDetailsUseCase(get(), get()) } + factory { SearchLettersUseCase(get()) } + factory { ReminderNotification(androidContext()) } + factory { UpsertReminderUseCase(get(), get()) } + factory { ReminderWithDetailsUseCase(get()) } + factory { AcknowledgeReminderUseCase(get(), get()) } + factory { DeleteReminderUseCase(get(), get()) } + factory { DeleteLetterUseCase(get(), get(), get()) } + factory { SaveCategoryOrderUseCase(get()) } + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/util/UtilKoin.kt b/app/src/main/java/net/frozendevelopment/openletters/util/UtilKoin.kt index 260e241..5bc488d 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/util/UtilKoin.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/util/UtilKoin.kt @@ -5,19 +5,30 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore -import org.koin.core.annotation.Factory -import org.koin.core.annotation.Module +import org.koin.dsl.module +// import org.koin.core.annotation.Factory +// import org.koin.core.annotation.Module val Context.dataStore: DataStore by preferencesDataStore(name = "user_preferences") -@Module -class UtilKoin { - @Factory - fun textExtractor(application: Application): TextExtractorType = TextExtractor(application) +// // @Module +// class UtilKoin { +// // @Factory +// fun textExtractor(application: Application): TextExtractorType = TextExtractor(application) +// +// // @Factory +// fun documentManager(application: Application): DocumentManagerType = DocumentManager(application) +// +// // @Factory +// fun themeManager(application: Application): ThemeManagerType = ThemeManager(datastore = application.dataStore) +// } - @Factory - fun documentManager(application: Application): DocumentManagerType = DocumentManager(application) - - @Factory - fun themeManager(application: Application): ThemeManagerType = ThemeManager(datastore = application.dataStore) -} +val utilKoinModule = + module { + factory { TextExtractor(get()) } + factory { DocumentManager(get()) } + factory { + val app: Application = get() + ThemeManager(datastore = app.dataStore) + } + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/work/WorkKoin.kt b/app/src/main/java/net/frozendevelopment/openletters/work/WorkKoin.kt index e332424..e4e8cab 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/work/WorkKoin.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/work/WorkKoin.kt @@ -1,18 +1,20 @@ package net.frozendevelopment.openletters.work -import android.content.Context -import androidx.work.WorkerParameters -import net.frozendevelopment.openletters.migration.AppMigrator -import org.koin.android.annotation.KoinWorker -import org.koin.core.annotation.Module -import org.koin.core.annotation.Provided +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.workmanager.dsl.worker +import org.koin.dsl.module -@Module -class WorkKoin { - @KoinWorker - fun appMigrationWorker( - context: Context, - @Provided parameters: WorkerParameters, - appMigrator: AppMigrator, - ): AppMigrationWorker = AppMigrationWorker(context, parameters, appMigrator) -} +// @Module +// class WorkKoin { +// @KoinWorker +// fun appMigrationWorker( +// context: Context, +// @Provided parameters: WorkerParameters, +// appMigrator: AppMigrator, +// ): AppMigrationWorker = AppMigrationWorker(context, parameters, appMigrator) +// } + +val workKoinModule = + module { + worker { AppMigrationWorker(androidContext(), get(), get()) } + } diff --git a/app/src/test/java/net/frozendevelopment/openletters/UpsertCategoryUseCaseTests.kt b/app/src/test/java/net/frozendevelopment/openletters/UpsertCategoryUseCaseTests.kt index 2718c3c..eb070bb 100644 --- a/app/src/test/java/net/frozendevelopment/openletters/UpsertCategoryUseCaseTests.kt +++ b/app/src/test/java/net/frozendevelopment/openletters/UpsertCategoryUseCaseTests.kt @@ -16,68 +16,66 @@ import java.time.ZoneOffset @RunWith(RobolectricTestRunner::class) class UpsertCategoryUseCaseTests { @Test - fun `should insert new category`() = - runBlocking { - val database = testDatabase() - val categoryId = CategoryId.random() - val time = LocalDateTime.ofEpochSecond(1000, 0, ZoneOffset.UTC) - val upsertCategory = UpsertCategoryUseCase(database.categoryQueries, { time }) + fun `should insert new category`() = runBlocking { + val database = testDatabase() + val categoryId = CategoryId.random() + val time = LocalDateTime.ofEpochSecond(1000, 0, ZoneOffset.UTC) + val upsertCategory = UpsertCategoryUseCase(database.categoryQueries, { time }) - val expectedCategory = - Category( - id = categoryId, - label = "Test Category", - color = Color.Blue, - created = time, - lastModified = time, - priority = Long.MAX_VALUE, - ) - - upsertCategory( + val expectedCategory = + Category( id = categoryId, label = "Test Category", color = Color.Blue, + created = time, + lastModified = time, + priority = Long.MAX_VALUE, ) - val category = database.categoryQueries.get(categoryId).executeAsOneOrNull() - Assert.assertEquals(expectedCategory, category) - } + upsertCategory( + id = categoryId, + label = "Test Category", + color = Color.Blue, + ) - @Test - fun `should update existing category`() = - runBlocking { - val database = testDatabase() - val categoryId = CategoryId.random() - val created = LocalDateTime.ofEpochSecond(50, 0, ZoneOffset.UTC) - val time = LocalDateTime.ofEpochSecond(1000, 0, ZoneOffset.UTC) - val upsertCategory = UpsertCategoryUseCase(database.categoryQueries, { time }) + val category = database.categoryQueries.get(categoryId).executeAsOneOrNull() + Assert.assertEquals(expectedCategory, category) + } - database.categoryQueries.upsert( - id = categoryId, - label = "Test Category", - color = Color.Black, - priority = 0, - created = created, - lastModified = created, - ) + @Test + fun `should update existing category`() = runBlocking { + val database = testDatabase() + val categoryId = CategoryId.random() + val created = LocalDateTime.ofEpochSecond(50, 0, ZoneOffset.UTC) + val time = LocalDateTime.ofEpochSecond(1000, 0, ZoneOffset.UTC) + val upsertCategory = UpsertCategoryUseCase(database.categoryQueries, { time }) - val expectedCategory = - Category( - id = categoryId, - label = "Updated Title", - color = Color.Blue, - created = created, - lastModified = time, - priority = Long.MAX_VALUE, - ) + database.categoryQueries.upsert( + id = categoryId, + label = "Test Category", + color = Color.Black, + priority = 0, + created = created, + lastModified = created, + ) - upsertCategory( + val expectedCategory = + Category( id = categoryId, label = "Updated Title", color = Color.Blue, + created = created, + lastModified = time, + priority = Long.MAX_VALUE, ) - val category = database.categoryQueries.get(categoryId).executeAsOneOrNull() - Assert.assertEquals(expectedCategory, category) - } + upsertCategory( + id = categoryId, + label = "Updated Title", + color = Color.Blue, + ) + + val category = database.categoryQueries.get(categoryId).executeAsOneOrNull() + Assert.assertEquals(expectedCategory, category) + } } diff --git a/app/src/test/java/net/frozendevelopment/openletters/UpsertReminderUseCaseTests.kt b/app/src/test/java/net/frozendevelopment/openletters/UpsertReminderUseCaseTests.kt index 50b5669..e9cc101 100644 --- a/app/src/test/java/net/frozendevelopment/openletters/UpsertReminderUseCaseTests.kt +++ b/app/src/test/java/net/frozendevelopment/openletters/UpsertReminderUseCaseTests.kt @@ -33,17 +33,16 @@ class UpsertReminderUseCaseTests { scheduledFor: LocalDateTime = LocalDateTime.ofEpochSecond(2000, 0, ZoneOffset.UTC), created: LocalDateTime = LocalDateTime.ofEpochSecond(500, 0, ZoneOffset.UTC), lastModified: LocalDateTime = LocalDateTime.ofEpochSecond(500, 0, ZoneOffset.UTC), - ): Reminder = - Reminder( - id = reminderId, - notificationId = notificationId, - title = title, - description = description, - scheduledFor = scheduledFor, - created = created, - acknowledged = acknowledged, - lastModified = lastModified, - ) + ): Reminder = Reminder( + id = reminderId, + notificationId = notificationId, + title = title, + description = description, + scheduledFor = scheduledFor, + created = created, + acknowledged = acknowledged, + lastModified = lastModified, + ) @Before fun setup() { diff --git a/app/src/test/java/net/frozendevelopment/openletters/util/TestDatabase.kt b/app/src/test/java/net/frozendevelopment/openletters/util/TestDatabase.kt index b888a74..d0480ef 100644 --- a/app/src/test/java/net/frozendevelopment/openletters/util/TestDatabase.kt +++ b/app/src/test/java/net/frozendevelopment/openletters/util/TestDatabase.kt @@ -2,17 +2,16 @@ package net.frozendevelopment.openletters.util import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import net.frozendevelopment.openletters.data.sqldelight.OpenLettersDB -import net.frozendevelopment.openletters.data.sqldelight.SqlDelightKoin +import net.frozendevelopment.openletters.extensions.invoke import org.sqlite.JDBC import java.sql.DriverManager fun testDatabase(): OpenLettersDB { DriverManager.registerDriver(JDBC()) - val driver = - JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).apply { - OpenLettersDB.Schema.create(this) - } + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).apply { + OpenLettersDB.Schema.create(this) + } - return SqlDelightKoin().openLettersDB(driver) + return OpenLettersDB(driver) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4ba7c5..5f1b98e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,33 @@ [versions] -agp = "8.11.1" -kotlin = "2.2.0" -coreKtx = "1.16.0" +agp = "8.13.2" +kotlin = "2.3.0" +coreKtx = "1.17.0" junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" -lifecycleRuntimeKtx = "2.9.2" -activityCompose = "1.10.1" -composeBom = "2025.07.00" -navigationCommonKtx = "2.9.2" -splashscreen = "1.0.1" -ksp = "2.2.0-2.0.2" -koin = "4.1.0" -koin-annotations = "2.1.0" -lifecycleRuntimeComposeAndroid = "2.9.2" -sqldelight = "2.1.0" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.2" +composeBom = "2025.12.01" +splashscreen = "1.2.0" +ksp = "2.3.4" +koin = "4.2.0-beta2" +koin-annotations = "2.3.1" +lifecycleRuntimeComposeAndroid = "2.10.0" +sqldelight = "2.2.1" camera = "1.4.0-rc01" accompanist = "0.35.1-alpha" mlkit = "16.0.1" -workmanager = "2.10.2" -colorpicker = "1.1.2" +workmanager = "2.11.0" +colorpicker = "1.1.3" serialization = "1.9.0" -adaptiveAndroid = "1.1.0" +adaptiveAndroid = "1.2.0" coreAnimation = "1.0.0" -uiTextGoogleFonts = "1.8.3" -datastoreCoreAndroid = "1.1.7" -ktlint = "13.0.0" -robolectric = "4.15.1" -mockk = "1.14.5" +uiTextGoogleFonts = "1.10.0" +datastoreCoreAndroid = "1.2.0" +ktlint = "13.1.0" +robolectric = "4.16" +mockk = "1.14.7" +nav3Core = "1.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +38,7 @@ mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "moc mockk-agent = { group = "io.mockk", name = "mockk-agent", version.ref = "mockk" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -47,24 +48,25 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version = "1.3.0-alpha04"} androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-core" } androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } -androidx-navigation = { group = "androidx.navigation", name = "navigation-compose" } -androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } +androidx-nav3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3Core" } +androidx-nav3-ui = { group = "androidx.navigation3", name = "navigation3-ui" } androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntimeComposeAndroid" } androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } -androidx-test-core = { group = "androidx.test", name = "core", version = "1.6.1" } +androidx-test-core = { group = "androidx.test", name = "core", version = "1.7.0" } androidx-workmanager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager"} androidx-workmanager-testing = { group = "androidx.work", name = "work-testing", version.ref = "workmanager"} mlkit-textrecognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "mlkit"} -mlkit-documentscanner = { group = "com.google.android.gms", name = "play-services-mlkit-document-scanner", version = "16.0.0-beta1"} +mlkit-documentscanner = { group = "com.google.android.gms", name = "play-services-mlkit-document-scanner", version = "16.0.0"} koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin" } koin-android = { group = "io.insert-koin", name = "koin-android" } koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose" } -koin-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation" } +koin-nav3 = { group = "io.insert-koin", name = "koin-compose-navigation3" } koin-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager"} koin-annotations = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koin-annotations" } koin-ksp = { group = "io.insert-koin", name = "koin-ksp-compiler", version.ref = "koin-annotations" } @@ -73,8 +75,8 @@ sqldelight = { group = "app.cash.sqldelight", name = "android-driver", version.r sqldelight-coroutines = { group = "app.cash.sqldelight", name = "coroutines-extensions", version.ref = "sqldelight" } sqldelight-primitive-adapters = { group = "app.cash.sqldelight", name = "primitive-adapters", version.ref = "sqldelight" } sqldelight-test = { group = "app.cash.sqldelight", name = "sqlite-driver", version.ref = "sqldelight" } -requery-sqlite = { group = "com.github.requery", name = "sqlite-android", version = "3.45.0" } -jdbc-sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.45.2.0" } +requery-sqlite = { group = "com.github.requery", name = "sqlite-android", version = "3.49.0" } +jdbc-sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.51.0.0" } colorpicker = { module = "com.github.skydoves:colorpicker-compose", version.ref = "colorpicker" }