From d2d9f2c427f2f8153f6b2c9441d76e282ea9ec4c Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 23 Nov 2025 21:31:07 -0500 Subject: [PATCH 1/6] nav3 migration --- .editorconfig | 13 + app/build.gradle.kts | 20 +- .../openletters/MainActivity.kt | 104 +++++--- .../openletters/OpenLettersApplication.kt | 37 ++- .../data/sqldelight/SqlDelightKoin.kt | 210 +++++++++------ .../extensions/NavControllerExtensions.kt | 23 -- .../openletters/feature/FeatureKoin.kt | 169 ++---------- .../feature/category/CategoryKoinModule.kt | 57 ++++ .../feature/category/CategoryNavigation.kt | 82 ------ .../category/form/CategoryFormDestination.kt | 55 ---- .../feature/category/form/CategoryFormView.kt | 53 +++- .../category/form/CategoryFormViewModel.kt | 14 +- .../category/manage/ManageCategoryView.kt | 47 +++- .../feature/letter/LetterKoinModule.kt | 111 ++++++++ .../feature/letter/LetterNavigation.kt | 244 ------------------ .../letter/detail/LetterDetailDestination.kt | 42 --- .../feature/letter/detail/LetterDetailView.kt | 41 +++ .../feature/letter/image/ImageView.kt | 30 ++- .../feature/letter/list/LetterListView.kt | 61 ++++- .../letter/scan/ScanLetterDestination.kt | 47 ---- .../feature/letter/scan/ScanLetterView.kt | 145 ++++++++++- .../feature/reminder/ReminderKoinModule.kt | 101 ++++++++ .../feature/reminder/ReminderNavigation.kt | 154 ----------- .../detail/ReminderDetailDestination.kt | 48 ---- .../reminder/detail/ReminderDetailView.kt | 56 ++++ .../reminder/form/ReminderFormDestination.kt | 71 ----- .../feature/reminder/form/ReminderFormView.kt | 71 +++++ .../feature/reminder/list/ReminderListView.kt | 64 ++++- .../feature/settings/SettingsKoinModule.kt | 33 +++ .../feature/settings/SettingsNavigation.kt | 34 --- .../feature/settings/SettingsView.kt | 30 +++ .../openletters/migration/AppMigrationKoin.kt | 47 ++-- .../openletters/migration/AppMigrator.kt | 2 +- .../ui/animation/NavigationTransitions.kt | 28 +- .../LettersNavDrawer.kt | 37 ++- .../openletters/ui/navigation/NavHost.kt | 73 ++++++ .../ui/navigation/NavigationState.kt | 82 ++++++ .../openletters/ui/navigation/Navigator.kt | 68 +++++ .../ui/preview/PreviewContainer.kt | 36 +++ .../openletters/usecase/UseCaseKoin.kt | 149 ++++++----- .../openletters/util/UtilKoin.kt | 35 ++- .../openletters/work/WorkKoin.kt | 32 +-- gradle/libs.versions.toml | 58 +++-- 43 files changed, 1653 insertions(+), 1261 deletions(-) create mode 100644 .editorconfig delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/extensions/NavControllerExtensions.kt create mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryNavigation.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormDestination.kt create mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterKoinModule.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/letter/LetterNavigation.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailDestination.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterDestination.kt create mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderKoinModule.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/reminder/ReminderNavigation.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailDestination.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormDestination.kt create mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsKoinModule.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsNavigation.kt rename app/src/main/java/net/frozendevelopment/openletters/ui/{components => navigation}/LettersNavDrawer.kt (83%) create mode 100644 app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt create mode 100644 app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavigationState.kt create mode 100644 app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt create mode 100644 app/src/main/java/net/frozendevelopment/openletters/ui/preview/PreviewContainer.kt 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/build.gradle.kts b/app/build.gradle.kts index 71674b4..052b39d 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" @@ -120,8 +120,10 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.material.icons) implementation(libs.androidx.material.icons.extended) - implementation(libs.androidx.navigation) - implementation(libs.androidx.navigation.common.ktx) +// 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.splashscreen) @@ -129,13 +131,15 @@ dependencies { implementation(libs.koin.android) implementation(libs.koin.compose) implementation(libs.koin.workmanager) - implementation(libs.koin.compose.navigation) +// 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..20dc46b 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt @@ -5,6 +5,10 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -13,46 +17,47 @@ 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.currentWindowAdaptiveInfo import androidx.compose.material3.rememberDrawerState +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.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.ui.NavDisplay import androidx.window.core.layout.WindowWidthSizeClass 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.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.navigation.EntryProvider +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.koinEntryProvider +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) override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -68,8 +73,14 @@ class MainActivity : ComponentActivity() { colorPalette = currentTheme.second, ) { val coroutineScope = rememberCoroutineScope() - val drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val navHostController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val navigationState = + rememberNavigationState( + LetterListDestination, + setOf(LetterListDestination, ManageCategoryDestination, ReminderListDestination), + ) + val navigator = remember { Navigator(navigationState) } + val entryProvider: EntryProvider = koinEntryProvider() // lock the app to portrait for phone users if (currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { @@ -80,27 +91,27 @@ class MainActivity : ComponentActivity() { drawerState = drawerState, goToMail = { coroutineScope.launch { drawerState.close() } - navHostController.newRoot(LetterListDestination) + navigator.navigate(LetterListDestination) }, goToManageCategories = { coroutineScope.launch { drawerState.close() } - navHostController.newRoot(ManageCategoryDestination) + navigator.navigate(ManageCategoryDestination) }, goToCreateCategory = { coroutineScope.launch { drawerState.close() } - navHostController.navigate(CategoryFormDestination()) + navigator.navigate(CategoryFormDestination()) }, goToReminders = { coroutineScope.launch { drawerState.close() } - navHostController.newRoot(ReminderListDestination) + navigator.navigate(ReminderListDestination) }, goToCreateReminder = { coroutineScope.launch { drawerState.close() } - navHostController.navigate(ReminderFormDestination()) + navigator.navigate(ReminderFormDestination()) }, goToSettings = { coroutineScope.launch { drawerState.close() } - navHostController.navigate(SettingsDestination) + navigator.navigate(SettingsDestination) }, ) { Scaffold(modifier = Modifier.fillMaxSize()) { _ -> @@ -115,23 +126,40 @@ class MainActivity : ComponentActivity() { ), ), ) { - 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), + onBack = { navigator.pop() }, + transitionSpec = { navigationEnterTransition() }, + popTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(400), + ) togetherWith + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(400), + ) + }, + predictivePopTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(400), + ) togetherWith + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(400), + ) + }, + ) + } + } } } } 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..ecea181 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,6 +1,5 @@ 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 @@ -16,80 +15,143 @@ 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 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 + val driver = get().apply { execute(null, "PRAGMA foreign_keys = ON;", 0) } + 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, + ), + ) + } - @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/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/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..6716fdf --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt @@ -0,0 +1,57 @@ +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..5a0053d 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,63 @@ 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::pop, + onSaveClicked = { + coroutineScope.launch { + viewModel.save() + navigator.pop() + } + }, + ) + } + } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -171,7 +222,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..64ab89a 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 { 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..a693262 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 @@ -23,6 +23,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 +43,63 @@ 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 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 { 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/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..2807c94 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 @@ -42,16 +42,57 @@ 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.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) +fun Module.letterDetailNavigation() = + navigation { 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::pop, + 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..c4debc8 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,43 @@ 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 +67,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) } 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..ac1b9ec 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 @@ -7,20 +7,79 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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) +fun Module.letterListNavigation() = + navigation { 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/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..ccf8fe6 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,156 @@ 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.pop() + } else { + navigator.navigate { backStack -> + backStack.add(0, LetterListDestination) + backStack.removeLastOrNull() + } + } + } + } + } + }, + onBackClicked = navigator::pop, + onDeleteDocumentClicked = viewModel::removeDocument, + onCreateCategoryClicked = { navigator.navigate(CategoryFormDestination(CategoryFormDestination.Mode.Create)) }, + ) + } + } + @Composable fun ScanLetterView( modifier: Modifier = Modifier, @@ -329,7 +469,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/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..d333d08 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 @@ -25,6 +29,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,11 +44,62 @@ 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.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) +fun Module.reminderDetailNavigation() = + navigation { 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::pop, + onAcknowledgeClicked = viewModel::acknowledge, + onLetterClicked = { navigator.navigate(LetterDetailDestination(it)) }, + ) + } + } @OptIn(ExperimentalMaterial3Api::class) @Composable 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..7a1dad7 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,81 @@ 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::pop, + onSaveClicked = { + coroutineScope.launch { + if (viewModel.save()) { + navigator.pop() + } + } + }, + ) + } + } + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReminderFormView( 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..7a2f3c3 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,10 @@ 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.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -22,25 +26,83 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.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 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) +fun Module.reminderListNavigation() = + navigation { 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..5f0af26 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,42 @@ 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::pop, + 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/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..69078dc 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,6 +1,7 @@ package net.frozendevelopment.openletters.ui.animation import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween @@ -8,30 +9,45 @@ 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.togetherWith +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene private const val DURATION = 100 -fun AnimatedContentTransitionScope.navigationEnterTransition(): EnterTransition = +fun AnimatedContentTransitionScope>.navigationEnterTransition(): ContentTransform = slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(DURATION), - ) + ) togetherWith scaleOut( + targetScale = .95f, + animationSpec = tween(DURATION), + ) + fadeOut(targetAlpha = .5f, animationSpec = tween(DURATION)) -fun AnimatedContentTransitionScope.navigationExitTransition(): ExitTransition = +fun AnimatedContentTransitionScope>.navigationExitTransition(): ExitTransition = scaleOut( targetScale = .95f, animationSpec = tween(DURATION), ) + fadeOut(targetAlpha = .5f, animationSpec = tween(DURATION)) -fun AnimatedContentTransitionScope.navigationPopEnterTransition(): EnterTransition = +fun AnimatedContentTransitionScope>.navigationPopEnterTransition(): EnterTransition = scaleIn( initialScale = .95f, animationSpec = tween(DURATION), ) + fadeIn(initialAlpha = .5f, animationSpec = tween(DURATION)) -fun AnimatedContentTransitionScope.navigationPopExitTransition(): ExitTransition = +fun AnimatedContentTransitionScope>.navigationPopExitTransition(): ExitTransition = slideOutOfContainer( towards = AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(DURATION), ) + scaleOut(targetScale = 1.05f, animationSpec = tween(DURATION)) + +fun AnimatedContentTransitionScope>.popTransition(): ContentTransform = + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(DURATION), + ) togetherWith scaleOut( + targetScale = .95f, + animationSpec = tween(DURATION), + ) + fadeOut(targetAlpha = .5f, animationSpec = tween(DURATION)) 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/NavHost.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt new file mode 100644 index 0000000..6e6f1d4 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt @@ -0,0 +1,73 @@ +package net.frozendevelopment.openletters.ui.navigation + +import android.media.CamcorderProfile.getAll +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +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 +} + +@OptIn(KoinExperimentalAPI::class) +@Composable +fun NavHost( + startRoute: NavKey, + topLevelRoutes: Set, + entryProvider: EntryProvider = koinEntryProvider(), +) { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val navigationState = rememberNavigationState(startRoute, topLevelRoutes) + val navigator = remember { Navigator(navigationState) } + + NavHost( + drawerState = drawerState, + navigationState = navigationState, + navigator = navigator, + ) { + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.pop() }, + ) + } +} + +@Composable +fun NavHost( + drawerState: DrawerState, + navigationState: NavigationState, + navigator: NavigatorType, + content: @Composable () -> Unit, +) { + CompositionLocalProvider(LocalDrawerState provides drawerState) { + CompositionLocalProvider(LocalNavigationState provides navigationState) { + CompositionLocalProvider( + LocalNavigator provides navigator, + ) { + content() + } + } + } +} 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..9c65983 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavigationState.kt @@ -0,0 +1,82 @@ +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.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(), + ) + 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..2bb9ca7 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt @@ -0,0 +1,68 @@ +package net.frozendevelopment.openletters.ui.navigation + +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 popUpTo(route: NavKey) +} + +@Stable +class Navigator( + val state: NavigationState, +) : 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 popUpTo(route: NavKey) { + val currentStack = state.backStacks[state.topLevelRoute] ?: error("No back stack for current route") + currentStack.indexOfLast { it == route }.takeIf { it != -1 }?.let { index -> + val itemsToRemove = currentStack.subList(index, currentStack.size) + currentStack.removeAll(itemsToRemove) + } + } +} + +class PreviewNavigator : NavigatorType { + override fun navigate(block: (NavBackStack) -> Unit) {} + + override fun navigate(route: NavKey) {} + + override fun pop() {} + + override fun popUpTo(route: NavKey) {} +} 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..8dc3032 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/preview/PreviewContainer.kt @@ -0,0 +1,36 @@ +package net.frozendevelopment.openletters.ui.preview + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavKey +import net.frozendevelopment.openletters.ui.navigation.NavHost +import net.frozendevelopment.openletters.ui.navigation.PreviewNavigator +import net.frozendevelopment.openletters.ui.navigation.rememberNavigationState +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 { + NavHost( + drawerState = rememberDrawerState(DrawerValue.Closed), + navigationState = rememberNavigationState(object : NavKey {}, setOf()), + navigator = PreviewNavigator(), + ) { + content() + } + } + } +} 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4ba7c5..6afc50b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,33 +1,34 @@ [versions] -agp = "8.11.1" -kotlin = "2.2.0" -coreKtx = "1.16.0" +agp = "8.13.1" +kotlin = "2.2.21" +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.0" +composeBom = "2025.11.01" +navigationCommonKtx = "2.9.6" +splashscreen = "1.2.0" +ksp = "2.3.3" +koin = "4.2.0-alpha2" +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.9.5" +datastoreCoreAndroid = "1.2.0" +ktlint = "13.1.0" +robolectric = "4.16" +mockk = "1.14.6" +nav3Core = "1.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,20 +52,23 @@ androidx-material-icons = { group = "androidx.compose.material", name = "materia 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 +77,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" } From bb208ed2f507227936495709c182e7fd8e23e4fe Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 23 Nov 2025 23:59:54 -0500 Subject: [PATCH 2/6] more progress on nav3 migration --- app/.editorconfig | 16 +- app/build.gradle.kts | 4 +- .../openletters/MainActivity.kt | 49 ++--- .../openletters/extensions/KoinExtensions.kt | 118 +++++++++++ .../feature/category/CategoryKoinModule.kt | 33 ++-- .../feature/category/form/CategoryFormView.kt | 39 ++-- .../category/form/CategoryFormViewModel.kt | 14 +- .../category/manage/ManageCategoryView.kt | 73 ++++--- .../manage/ManageCategoryViewModel.kt | 37 ++-- .../feature/letter/detail/LetterDetailView.kt | 37 ++-- .../feature/letter/image/ImageView.kt | 32 ++- .../feature/letter/list/LetterListView.kt | 85 ++++---- .../letter/list/LetterListViewModel.kt | 39 ++-- .../feature/letter/scan/ScanLetterView.kt | 185 +++++++++--------- .../feature/letter/scan/ScanViewModel.kt | 112 +++++------ .../reminder/detail/ReminderDetailView.kt | 45 +++-- .../detail/ReminderDetailViewModel.kt | 17 +- .../feature/reminder/form/ReminderFormView.kt | 75 ++++--- .../reminder/form/ReminderFormViewModel.kt | 97 +++++---- .../feature/reminder/list/ReminderListView.kt | 87 ++++---- .../feature/settings/SettingsView.kt | 29 ++- .../feature/settings/SettingsViewModel.kt | 18 +- .../ui/animation/NavigationTransitions.kt | 67 ++++--- .../openletters/ui/navigation/NavHost.kt | 22 +-- .../ui/navigation/NavigationState.kt | 2 + .../usecase/SearchLettersUseCase.kt | 31 ++- .../openletters/UpsertCategoryUseCaseTests.kt | 96 +++++---- .../openletters/UpsertReminderUseCaseTests.kt | 21 +- gradle/libs.versions.toml | 4 +- 29 files changed, 785 insertions(+), 699 deletions(-) create mode 100644 app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt 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 052b39d..19aed4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,18 +120,16 @@ dependencies { implementation(libs.androidx.material3) 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) diff --git a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt index 20dc46b..c81242e 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt @@ -5,10 +5,6 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -28,6 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.coroutines.launch @@ -38,7 +35,8 @@ import net.frozendevelopment.openletters.feature.letter.list.LetterListDestinati import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination import net.frozendevelopment.openletters.feature.reminder.list.ReminderListDestination import net.frozendevelopment.openletters.feature.settings.SettingsDestination -import net.frozendevelopment.openletters.ui.animation.navigationEnterTransition +import net.frozendevelopment.openletters.ui.animation.popTransitionSpec +import net.frozendevelopment.openletters.ui.animation.pushTransitionSpec import net.frozendevelopment.openletters.ui.navigation.EntryProvider import net.frozendevelopment.openletters.ui.navigation.LettersNavDrawer import net.frozendevelopment.openletters.ui.navigation.LocalDrawerState @@ -74,11 +72,14 @@ class MainActivity : ComponentActivity() { ) { val coroutineScope = rememberCoroutineScope() val drawerState = rememberDrawerState(DrawerValue.Closed) - val navigationState = - rememberNavigationState( + val navigationState = rememberNavigationState( + LetterListDestination, + setOf( LetterListDestination, - setOf(LetterListDestination, ManageCategoryDestination, ReminderListDestination), - ) + ManageCategoryDestination, + ReminderListDestination, + ), + ) val navigator = remember { Navigator(navigationState) } val entryProvider: EntryProvider = koinEntryProvider() @@ -133,30 +134,14 @@ class MainActivity : ComponentActivity() { ) { NavDisplay( entries = navigationState.toEntries(entryProvider), +// entryDecorators = listOf( +// rememberSaveableStateHolderNavEntryDecorator(), +// rememberViewModelStoreNavEntryDecorator() +// ), onBack = { navigator.pop() }, - transitionSpec = { navigationEnterTransition() }, - popTransitionSpec = { - // Slide in from left when navigating back - slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = tween(400), - ) togetherWith - slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(400), - ) - }, - predictivePopTransitionSpec = { - // Slide in from left when navigating back - slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = tween(400), - ) togetherWith - slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(400), - ) - }, + transitionSpec = { pushTransitionSpec() }, + popTransitionSpec = { popTransitionSpec() }, + predictivePopTransitionSpec = { popTransitionSpec() }, ) } } 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..42436fa --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt @@ -0,0 +1,118 @@ +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.compose.navigation3.EntryProviderInstaller +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.annotation.KoinInternalApi +import org.koin.core.definition.KoinDefinition +import org.koin.core.module.KoinDslMarker +import org.koin.core.module.Module +import org.koin.core.module._scopedInstanceFactory +import org.koin.core.module._singleInstanceFactory +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope +import org.koin.dsl.ScopeDSL +import org.koin.dsl.navigation3.navigation + +/** + * Declares a scoped navigation entry within a Koin scope DSL. + * + * This function registers a composable navigation destination that is scoped to a specific Koin scope, + * allowing access to scoped dependencies within the composable. The route type [T] is used as both + * the navigation destination identifier and a qualifier for the entry provider. + * + * Example usage: + * ```kotlin + * activityScope { + * viewModel { MyViewModel() } + * navigation { route -> + * MyScreen(viewModel = koinViewModel()) + * } + * } + * ``` + * + * @param T The type representing the navigation route/destination + * @param definition A composable function that receives the [Scope] and route instance [T] to render the destination + * @return A [KoinDefinition] for the created [EntryProviderInstaller] + * + * @see Module.navigation for module-level navigation entries + */ +@KoinExperimentalAPI +@KoinDslMarker +@OptIn(KoinInternalApi::class) +inline fun ScopeDSL.navigation( + metadata: Map = emptyMap(), + noinline definition: @Composable Scope.(T) -> Unit, +): KoinDefinition { + val def = _scopedInstanceFactory(named(), { + val scope = this { + entry( + metadata = metadata, + content = { t -> definition(scope, t) }, + ) + } + }, scopeQualifier) + module.indexPrimaryType(def) + return KoinDefinition(module, def) +} + +/** + * Declares a singleton navigation entry within a Koin module. + * + * This function registers a composable navigation destination as a singleton in the Koin module, + * allowing access to module-level dependencies within the composable. The route type [T] is used + * as both the navigation destination identifier and a qualifier for the entry provider. + * + * Example usage: + * ```kotlin + * module { + * viewModel { MyViewModel() } + * navigation { route -> + * HomeScreen(myViewModel = koinViewModel()) + * } + * } + * ``` + * + * @param T The type representing the navigation route/destination + * @param definition A composable function that receives the [Scope] and route instance [T] to render the destination + * @return A [KoinDefinition] for the created [EntryProviderInstaller] + * + * @see ScopeDSL.navigation for scope-level navigation entries + */ +@KoinExperimentalAPI +@KoinDslMarker +@OptIn(KoinInternalApi::class) +inline fun Module.navigation( + metadata: Map = emptyMap(), + noinline definition: @Composable Scope.(T) -> Unit, +): KoinDefinition { + val def = _singleInstanceFactory(named(), { + val scope = this { + entry( + metadata = metadata, + content = { t -> definition(scope, t) }, + ) + } + }) + indexPrimaryType(def) + return KoinDefinition(this, def) +} + +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/feature/category/CategoryKoinModule.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt index 6716fdf..05b2ef8 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/CategoryKoinModule.kt @@ -36,22 +36,21 @@ import org.koin.dsl.module // } // } -val categoryKoinModule = - module { - categoryFormNavigation() - manageCategoryNavigation() - viewModel { (mode: CategoryFormDestination.Mode) -> - CategoryFormViewModel( - mode = mode, - upsertCategoryUseCase = get(), - categoryQueries = get(), - ) - } +val categoryKoinModule = module { + categoryFormNavigation() + manageCategoryNavigation() + viewModel { (mode: CategoryFormDestination.Mode) -> + CategoryFormViewModel( + mode = mode, + upsertCategoryUseCase = get(), + categoryQueries = get(), + ) + } - viewModel { - ManageCategoryViewModel( - saveCategoryOrder = get(), - categoryQueries = get(), - ) - } + viewModel { + ManageCategoryViewModel( + saveCategoryOrder = get(), + categoryQueries = get(), + ) } +} 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 5a0053d..f113947 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 @@ -69,28 +69,27 @@ data class CategoryFormDestination( } @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() +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::pop, - onSaveClicked = { - coroutineScope.launch { - viewModel.save() - navigator.pop() - } - }, - ) - } + Surface { + CategoryFormView( + state = state, + onLabelChanged = viewModel::setLabel, + onColorChanged = viewModel::setColor, + onBackClicked = navigator::pop, + onSaveClicked = { + coroutineScope.launch { + viewModel.save() + navigator.pop() + } + }, + ) } +} @OptIn(ExperimentalMaterial3Api::class) @Composable 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 64ab89a..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 @@ -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 a693262..8b799e4 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 @@ -45,9 +48,11 @@ 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.extensions.navigation 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 @@ -56,7 +61,6 @@ 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 : NavKey @@ -66,39 +70,46 @@ private data class DraggableItem( ) @OptIn(KoinExperimentalAPI::class) -fun Module.manageCategoryNavigation() = - navigation { route -> - val drawerState = LocalDrawerState.current - val navigator = LocalNavigator.current - val viewModel = koinViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val coroutineScope = rememberCoroutineScope() +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() - } + 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, - ) - } + } + }, + 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 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/detail/LetterDetailView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt index 2807c94..3a72a4f 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 @@ -72,27 +72,26 @@ data class LetterDetailDestination( ) : NavKey @OptIn(KoinExperimentalAPI::class) -fun Module.letterDetailNavigation() = - navigation { route -> - val navigator = LocalNavigator.current - val viewModel: LetterDetailViewModel = koinViewModel { parametersOf(route.letterId) } - val state by viewModel.stateFlow.collectAsStateWithLifecycle() +fun Module.letterDetailNavigation() = navigation { 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::pop, - onImageClick = { uri -> navigator.navigate(ImageDestination(uri.toString())) }, - ) - } + Surface { + LetterDetailView( + modifier = Modifier.fillMaxSize(), + state = state, + onEditClicked = { navigator.navigate(ScanLetterDestination(route.letterId)) }, + onCreateReminderClicked = { + navigator.navigate( + ReminderFormDestination(preselectedLetters = listOf(route.letterId)), + ) + }, + onBackClicked = navigator::pop, + 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 c4debc8..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 @@ -47,19 +47,18 @@ data class ImageDestination( ) : 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 = {}, - ) - } +fun Module.imageViewNavigation() = navigation { route -> + Surface( + color = Color.Black, + contentColor = Color.White, + ) { + ImageView( + modifier = Modifier.fillMaxSize(), + uri = route.uri.toUri(), + onBackClick = {}, + ) } +} @Composable fun ImageView( @@ -156,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 ac1b9ec..a9bfe4f 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 @@ -1,5 +1,8 @@ package net.frozendevelopment.openletters.feature.letter.list +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -15,11 +18,13 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark 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.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId +import net.frozendevelopment.openletters.extensions.navigation 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 @@ -32,54 +37,56 @@ 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 : NavKey @OptIn(KoinExperimentalAPI::class) -fun Module.letterListNavigation() = - navigation { route -> - val drawerState = LocalDrawerState.current - val navigator = LocalNavigator.current +fun Module.letterListNavigation() = navigation( + metadata = NavDisplay.transitionSpec { + EnterTransition.None togetherWith ExitTransition.None + }, +) { route -> + val drawerState = LocalDrawerState.current + val navigator = LocalNavigator.current - val coroutineScope = rememberCoroutineScope() - val viewModel: LetterListViewModel = koinViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() + 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)) + Surface { + LetterListView( + modifier = Modifier.fillMaxSize(), + state = state, + onNavDrawerClicked = { + coroutineScope.launch { + drawerState.apply { + if (isClosed) open() else close() } - }, - onCreateReminderClicked = { navigator.navigate(ReminderFormDestination(preselectedLetters = it)) }, - ) - } + } + }, + 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/ScanLetterView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt index ccf8fe6..3890f38 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 @@ -82,111 +82,110 @@ data class ScanLetterDestination( ) : 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() +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 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 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) - } + 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.pop() - } else { - navigator.navigate { backStack -> - backStack.add(0, LetterListDestination) - backStack.removeLastOrNull() - } + 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.pop() + } else { + navigator.navigate { backStack -> + backStack.add(0, LetterListDestination) + backStack.removeLastOrNull() } } } } - }, - onBackClicked = navigator::pop, - onDeleteDocumentClicked = viewModel::removeDocument, - onCreateCategoryClicked = { navigator.navigate(CategoryFormDestination(CategoryFormDestination.Mode.Create)) }, - ) - } + } + }, + onBackClicked = navigator::pop, + onDeleteDocumentClicked = viewModel::removeDocument, + onCreateCategoryClicked = { navigator.navigate(CategoryFormDestination(CategoryFormDestination.Mode.Create)) }, + ) } +} @Composable fun ScanLetterView( 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/detail/ReminderDetailView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/detail/ReminderDetailView.kt index d333d08..41fed9f 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 @@ -72,34 +72,33 @@ data class ReminderDetailDestination( } @OptIn(KoinExperimentalAPI::class) -fun Module.reminderDetailNavigation() = - navigation { route -> - val navigator = LocalNavigator.current - val viewModel = koinViewModel { parametersOf(route.reminderId) } - val state by viewModel.stateFlow.collectAsStateWithLifecycle() +fun Module.reminderDetailNavigation() = navigation { 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, - ) + 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) - } + 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::pop, - onAcknowledgeClicked = viewModel::acknowledge, - onLetterClicked = { navigator.navigate(LetterDetailDestination(it)) }, - ) - } + Surface { + ReminderDetailScreen( + modifier = Modifier.fillMaxWidth(), + state = state, + onBackClicked = navigator::pop, + 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/ReminderFormView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt index 7a1dad7..065ca95 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 @@ -81,50 +81,49 @@ data class ReminderFormDestination( ) : 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() +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, - ) + 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) - } + 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::pop, - onSaveClicked = { - coroutineScope.launch { - if (viewModel.save()) { - navigator.pop() - } + 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::pop, + onSaveClicked = { + coroutineScope.launch { + if (viewModel.save()) { + navigator.pop() } - }, - ) - } + } + }, + ) } +} @OptIn(ExperimentalMaterial3Api::class) @Composable 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 7a2f3c3..da4bf4c 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 @@ -5,6 +5,9 @@ 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 @@ -39,10 +42,12 @@ 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.navigation import net.frozendevelopment.openletters.extensions.openAppSettings import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination @@ -54,55 +59,61 @@ 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 : NavKey @OptIn(KoinExperimentalAPI::class) -fun Module.reminderListNavigation() = - navigation { route -> - val navigator = LocalNavigator.current - val drawerState = LocalDrawerState.current - val coroutineScope = rememberCoroutineScope() - val viewModel = koinViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() +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 + }, +) { 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, - ) + 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) - } + 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)) + Surface { + ReminderListView( + modifier = Modifier.fillMaxSize(), + state = state, + openNavigationDrawer = { + coroutineScope.launch { + drawerState.apply { + if (isClosed) open() else close() } - }, - createReminderClicked = { navigator.navigate(ReminderFormDestination()) }, - onDeleteReminderClicked = viewModel::delete, - ) - } + } + }, + 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/SettingsView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/settings/SettingsView.kt index 5f0af26..033c456 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 @@ -44,23 +44,22 @@ import org.koin.dsl.navigation3.navigation 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() +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::pop, - onThemeChanged = viewModel::setTheme, - onColorVariantChanged = viewModel::setVariant, - onViewSourceClicked = {}, // { navigator.openUrl("https://github.com/frozenjava/OpenLetters") }, - ) - } + Surface { + SettingsView( + modifier = Modifier.fillMaxSize(), + state = state, + onBackClicked = navigator::pop, + 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/ui/animation/NavigationTransitions.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/animation/NavigationTransitions.kt index 69078dc..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 @@ -2,52 +2,59 @@ package net.frozendevelopment.openletters.ui.animation import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition 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.compose.animation.slideInHorizontally -import androidx.compose.animation.togetherWith +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(): ContentTransform = - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Start, +val pushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = { + val incoming = slideInHorizontally( animationSpec = tween(DURATION), - ) togetherWith scaleOut( - targetScale = .95f, + ) { fullWidth -> fullWidth } + val outgoing = scaleOut( + targetScale = 0.95f, animationSpec = tween(DURATION), - ) + fadeOut(targetAlpha = .5f, animationSpec = tween(DURATION)) - -fun AnimatedContentTransitionScope>.navigationExitTransition(): ExitTransition = - scaleOut( - targetScale = .95f, + ) + fadeOut( + targetAlpha = 0.5f, animationSpec = tween(DURATION), - ) + fadeOut(targetAlpha = .5f, animationSpec = tween(DURATION)) + ) -fun AnimatedContentTransitionScope>.navigationPopEnterTransition(): EnterTransition = - scaleIn( - initialScale = .95f, - animationSpec = tween(DURATION), - ) + fadeIn(initialAlpha = .5f, animationSpec = tween(DURATION)) + ContentTransform( + targetContentEnter = incoming, + initialContentExit = outgoing, + sizeTransform = null, + ) +} -fun AnimatedContentTransitionScope>.navigationPopExitTransition(): ExitTransition = - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.End, +val popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = { + val outgoing = slideOutHorizontally( + animationSpec = tween(DURATION), + ) { fullWidth -> fullWidth } + scaleOut( + targetScale = 1.05f, animationSpec = tween(DURATION), - ) + scaleOut(targetScale = 1.05f, animationSpec = tween(DURATION)) + ) + fadeOut( + targetAlpha = 0.5f, + animationSpec = tween(DURATION), + ) -fun AnimatedContentTransitionScope>.popTransition(): ContentTransform = - slideInHorizontally( - initialOffsetX = { -it }, + val incoming = scaleIn( + initialScale = 0.95f, animationSpec = tween(DURATION), - ) togetherWith scaleOut( - targetScale = .95f, + ) + fadeIn( + initialAlpha = 0.5f, animationSpec = tween(DURATION), - ) + fadeOut(targetAlpha = .5f, animationSpec = tween(DURATION)) + ) + + ContentTransform( + targetContentEnter = incoming, + initialContentExit = outgoing, + sizeTransform = null, + ) +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt index 6e6f1d4..d5b26bf 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt @@ -1,35 +1,17 @@ package net.frozendevelopment.openletters.ui.navigation -import android.media.CamcorderProfile.getAll import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.navigation3.runtime.EntryProviderScope -import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay -import org.koin.compose.LocalKoinScopeContext +import net.frozendevelopment.openletters.extensions.EntryProvider +import net.frozendevelopment.openletters.extensions.koinEntryProvider 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 -} @OptIn(KoinExperimentalAPI::class) @Composable 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 index 9c65983..f21bbfe 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavigationState.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavigationState.kt @@ -10,6 +10,7 @@ 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 @@ -68,6 +69,7 @@ fun NavigationState.toEntries(entryProvider: (NavKey) -> NavEntry): Snap val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), ) rememberDecoratedNavEntries( backStack = stack, 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6afc50b..d8f82c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,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" } @@ -50,8 +51,6 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 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" } @@ -67,7 +66,6 @@ mlkit-documentscanner = { group = "com.google.android.gms", name = "play-service 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" } From 70e3d31def2962fe37d5a056afd9aa262ee07d6a Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 25 Nov 2025 21:42:06 -0500 Subject: [PATCH 3/6] started working on nav3 split scene --- app/build.gradle.kts | 1 + .../openletters/MainActivity.kt | 66 +++++++----- .../openletters/extensions/KoinExtensions.kt | 15 +-- .../feature/category/form/CategoryFormView.kt | 2 +- .../feature/letter/detail/LetterDetailView.kt | 12 ++- .../feature/letter/image/ImageView.kt | 2 +- .../feature/letter/list/LetterListView.kt | 7 +- .../feature/letter/scan/ScanLetterView.kt | 2 +- .../reminder/detail/ReminderDetailView.kt | 2 +- .../feature/reminder/form/ReminderFormView.kt | 2 +- .../feature/settings/SettingsView.kt | 2 +- .../ui/navigation/ListDetailScene.kt | 101 ++++++++++++++++++ gradle/libs.versions.toml | 1 + 13 files changed, 174 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 19aed4b..29844bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,6 +118,7 @@ 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.nav3.runtime) diff --git a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt index c81242e..200ba65 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt @@ -15,20 +15,31 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding 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.rememberSupportingPaneSceneStrategy import androidx.compose.material3.rememberDrawerState 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.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SinglePaneSceneStrategy import androidx.navigation3.ui.NavDisplay +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.coroutines.launch import net.frozendevelopment.openletters.data.sqldelight.LetterQueries +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.list.LetterListDestination @@ -37,13 +48,12 @@ import net.frozendevelopment.openletters.feature.reminder.list.ReminderListDesti import net.frozendevelopment.openletters.feature.settings.SettingsDestination import net.frozendevelopment.openletters.ui.animation.popTransitionSpec import net.frozendevelopment.openletters.ui.animation.pushTransitionSpec -import net.frozendevelopment.openletters.ui.navigation.EntryProvider 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.koinEntryProvider +import net.frozendevelopment.openletters.ui.navigation.rememberListDetailSceneStrategy import net.frozendevelopment.openletters.ui.navigation.rememberNavigationState import net.frozendevelopment.openletters.ui.navigation.toEntries import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme @@ -55,7 +65,7 @@ class MainActivity : ComponentActivity() { private val themeManager: ThemeManagerType by inject() private val letterQueries: LetterQueries by inject() - @OptIn(KoinExperimentalAPI::class) + @OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3AdaptiveApi::class) override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -65,28 +75,39 @@ class MainActivity : ComponentActivity() { setContent { val currentTheme by themeManager.current.collectAsStateWithLifecycle() + val listDetailSceneStrategy = rememberListDetailSceneStrategy() + + val coroutineScope = rememberCoroutineScope() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val navigationState = rememberNavigationState( + LetterListDestination, + setOf( + LetterListDestination, + ManageCategoryDestination, + ReminderListDestination, + ), + ) + val navigator = remember { Navigator(navigationState) } + val entryProvider: EntryProvider = koinEntryProvider() + + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp) + } + + // Override the defaults so that the supporting pane can be dismissed by pressing back. + // See b/445826749 + val supportingPaneStrategy = rememberSupportingPaneSceneStrategy( + backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange, + directive = directive + ) + OpenLettersTheme( appTheme = currentTheme.first, colorPalette = currentTheme.second, ) { - val coroutineScope = rememberCoroutineScope() - val drawerState = rememberDrawerState(DrawerValue.Closed) - val navigationState = rememberNavigationState( - LetterListDestination, - setOf( - LetterListDestination, - ManageCategoryDestination, - ReminderListDestination, - ), - ) - val navigator = remember { Navigator(navigationState) } - val entryProvider: EntryProvider = koinEntryProvider() - - // lock the app to portrait for phone users - if (currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } LettersNavDrawer( drawerState = drawerState, @@ -134,10 +155,7 @@ class MainActivity : ComponentActivity() { ) { NavDisplay( entries = navigationState.toEntries(entryProvider), -// entryDecorators = listOf( -// rememberSaveableStateHolderNavEntryDecorator(), -// rememberViewModelStoreNavEntryDecorator() -// ), + sceneStrategy = supportingPaneStrategy, onBack = { navigator.pop() }, transitionSpec = { pushTransitionSpec() }, popTransitionSpec = { popTransitionSpec() }, diff --git a/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt b/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt index 42436fa..dee5304 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt @@ -17,7 +17,7 @@ import org.koin.core.module._singleInstanceFactory import org.koin.core.qualifier.named import org.koin.core.scope.Scope import org.koin.dsl.ScopeDSL -import org.koin.dsl.navigation3.navigation +import net.frozendevelopment.openletters.extensions.navigation /** * Declares a scoped navigation entry within a Koin scope DSL. @@ -45,15 +45,16 @@ import org.koin.dsl.navigation3.navigation @KoinExperimentalAPI @KoinDslMarker @OptIn(KoinInternalApi::class) -inline fun ScopeDSL.navigation( +inline fun ScopeDSL.navigation( metadata: Map = emptyMap(), noinline definition: @Composable Scope.(T) -> Unit, ): KoinDefinition { val def = _scopedInstanceFactory(named(), { - val scope = this { + val scope = this + { entry( metadata = metadata, - content = { t -> definition(scope, t) }, + content = { t -> definition(scope, t) } ) } }, scopeQualifier) @@ -92,10 +93,11 @@ inline fun Module.navigation( noinline definition: @Composable Scope.(T) -> Unit, ): KoinDefinition { val def = _singleInstanceFactory(named(), { - val scope = this { + val scope = this + { entry( metadata = metadata, - content = { t -> definition(scope, t) }, + content = { t -> definition(scope, t) } ) } }) @@ -103,6 +105,7 @@ inline fun Module.navigation( return KoinDefinition(this, def) } + typealias EntryProvider = (NavKey) -> NavEntry @OptIn(KoinExperimentalAPI::class, KoinInternalApi::class) 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 f113947..684efdb 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 @@ -51,7 +51,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation @Serializable data class CategoryFormDestination( 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 3a72a4f..b46ee83 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.SupportingPaneSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -52,27 +54,31 @@ 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.extensions.navigation import net.frozendevelopment.openletters.feature.letter.image.ImageDestination 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.ListDetailScene.Companion.detailPane 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) -fun Module.letterDetailNavigation() = navigation { route -> +@OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) +fun Module.letterDetailNavigation() = navigation( + metadata = SupportingPaneSceneStrategy.supportingPane() +) { route -> val navigator = LocalNavigator.current val viewModel: LetterDetailViewModel = koinViewModel { parametersOf(route.letterId) } val state by viewModel.stateFlow.collectAsStateWithLifecycle() 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 4f011ef..6ca8fce 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 @@ -39,7 +39,7 @@ 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 +import net.frozendevelopment.openletters.extensions.navigation @Serializable data class ImageDestination( 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 a9bfe4f..055b4ef 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 @@ -9,6 +9,8 @@ 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.SupportingPaneSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope @@ -31,6 +33,7 @@ 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.ListDetailScene.Companion.listPane import net.frozendevelopment.openletters.ui.navigation.LocalDrawerState import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme @@ -41,11 +44,11 @@ import org.koin.core.module.Module @Serializable data object LetterListDestination : NavKey -@OptIn(KoinExperimentalAPI::class) +@OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) fun Module.letterListNavigation() = navigation( metadata = NavDisplay.transitionSpec { EnterTransition.None togetherWith ExitTransition.None - }, + } + SupportingPaneSceneStrategy.mainPane() ) { route -> val drawerState = LocalDrawerState.current val navigator = LocalNavigator.current 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 3890f38..0fd04e0 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 @@ -72,7 +72,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation import java.time.LocalDateTime @Serializable 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 41fed9f..f4ea1e7 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 @@ -60,7 +60,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation @Serializable data class ReminderDetailDestination( 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 065ca95..cb633ed 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 @@ -69,7 +69,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation import java.text.SimpleDateFormat import java.util.Date import java.util.Locale 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 033c456..832b94a 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 @@ -38,7 +38,7 @@ 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 +import net.frozendevelopment.openletters.extensions.navigation @Serializable data object SettingsDestination : NavKey diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt new file mode 100644 index 0000000..38ca267 --- /dev/null +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt @@ -0,0 +1,101 @@ +package net.frozendevelopment.openletters.ui.navigation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import androidx.navigation3.ui.NavDisplay +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND + +class ListDetailScene( + override val key: Any, + override val previousEntries: List>, + val listEntry: NavEntry, + val detailEntry: NavEntry, +) : Scene { + override val entries: List> = listOf(listEntry, detailEntry) + override val content: @Composable (() -> Unit) = { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(0.4f)) { + listEntry.Content() + } + Column(modifier = Modifier.weight(0.6f)) { + AnimatedContent( + targetState = detailEntry, + contentKey = { entry -> entry.contentKey }, + transitionSpec = { + slideInHorizontally( + initialOffsetX = { it } + ) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + } + ) { entry -> + entry.Content() + } + } + } + } + + companion object { + internal const val LIST_KEY = "ListDetailScene-List" + internal const val DETAIL_KEY = "ListDetailScene-Detail" + + fun listPane() = mapOf(LIST_KEY to true) + NavDisplay.transitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.popTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.predictivePopTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + fun detailPane() = mapOf(DETAIL_KEY to true) + NavDisplay.transitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.popTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + NavDisplay.predictivePopTransitionSpec { + EnterTransition.None togetherWith ExitTransition.None + } + } +} + +@Composable +fun rememberListDetailSceneStrategy(): ListDetailSceneStrategy { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + + return remember(windowSizeClass) { + ListDetailSceneStrategy(windowSizeClass) + } +} + +class ListDetailSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy { + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { + return null + } + + val detailEntry = entries.lastOrNull()?.takeIf { it.metadata.containsKey(ListDetailScene.DETAIL_KEY) } ?: return null + val listEntry = entries.findLast { it.metadata.containsKey(ListDetailScene.LIST_KEY) } ?: return null + + val sceneKey = listEntry.contentKey + + return ListDetailScene( + sceneKey, + entries.dropLast(1), + listEntry, + detailEntry + ) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8f82c9..fbcea33 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ 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-nav3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3Core" } From 4cb686e3dc0dbe439d1f77d209d5f6294206a618 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 22 Dec 2025 14:33:21 -0500 Subject: [PATCH 4/6] Completed navigation-3 migration --- app/build.gradle.kts | 9 +- .../openletters/MainActivity.kt | 175 +++++++++--------- .../openletters/extensions/KoinExtensions.kt | 96 ---------- .../feature/category/form/CategoryFormView.kt | 2 +- .../category/manage/ManageCategoryView.kt | 2 +- .../feature/letter/detail/LetterDetailView.kt | 11 +- .../feature/letter/image/ImageView.kt | 2 +- .../feature/letter/list/LetterListView.kt | 13 +- .../feature/letter/scan/ScanLetterView.kt | 2 +- .../reminder/detail/ReminderDetailView.kt | 11 +- .../feature/reminder/form/ReminderFormView.kt | 2 +- .../feature/reminder/list/ReminderListView.kt | 8 +- .../feature/settings/SettingsView.kt | 2 +- .../ui/navigation/ListDetailScene.kt | 101 ---------- .../openletters/ui/navigation/NavHost.kt | 55 ------ .../openletters/ui/navigation/Navigator.kt | 14 +- .../ui/preview/PreviewContainer.kt | 14 +- gradle/libs.versions.toml | 17 +- 18 files changed, 132 insertions(+), 404 deletions(-) delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt delete mode 100644 app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29844bc..c621f8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt index 200ba65..cc7013d 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt @@ -1,6 +1,5 @@ package net.frozendevelopment.openletters -import android.content.pm.ActivityInfo import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -19,8 +18,9 @@ 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.rememberSupportingPaneSceneStrategy +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 @@ -30,14 +30,8 @@ import androidx.compose.ui.unit.dp import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.scene.SceneStrategy -import androidx.navigation3.scene.SinglePaneSceneStrategy import androidx.navigation3.ui.NavDisplay -import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND -import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.coroutines.launch -import net.frozendevelopment.openletters.data.sqldelight.LetterQueries import net.frozendevelopment.openletters.extensions.EntryProvider import net.frozendevelopment.openletters.extensions.koinEntryProvider import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination @@ -53,7 +47,6 @@ 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.rememberListDetailSceneStrategy import net.frozendevelopment.openletters.ui.navigation.rememberNavigationState import net.frozendevelopment.openletters.ui.navigation.toEntries import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme @@ -63,7 +56,6 @@ 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?) { @@ -74,94 +66,95 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - val currentTheme by themeManager.current.collectAsStateWithLifecycle() - val listDetailSceneStrategy = rememberListDetailSceneStrategy() + App() + } + } - val coroutineScope = rememberCoroutineScope() - val drawerState = rememberDrawerState(DrawerValue.Closed) - val navigationState = rememberNavigationState( - LetterListDestination, - setOf( - LetterListDestination, - ManageCategoryDestination, - ReminderListDestination, - ), - ) - val navigator = remember { Navigator(navigationState) } - val entryProvider: EntryProvider = koinEntryProvider() + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + @Composable + private fun App() { + val currentTheme by themeManager.current.collectAsStateWithLifecycle() - val windowAdaptiveInfo = currentWindowAdaptiveInfo() - val directive = remember(windowAdaptiveInfo) { - calculatePaneScaffoldDirective(windowAdaptiveInfo) - .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp) - } + val coroutineScope = rememberCoroutineScope() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val navigationState = rememberNavigationState( + LetterListDestination, + setOf( + LetterListDestination, + ManageCategoryDestination, + ReminderListDestination, + ), + ) + val navigator = remember { Navigator(navigationState, onBackPressedDispatcher) } + val entryProvider: EntryProvider = koinEntryProvider() - // Override the defaults so that the supporting pane can be dismissed by pressing back. - // See b/445826749 - val supportingPaneStrategy = rememberSupportingPaneSceneStrategy( - backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange, - directive = directive - ) + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp) + } + val supportingPaneStrategy = rememberListDetailSceneStrategy( + backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange, + directive = directive, + ) - OpenLettersTheme( - appTheme = currentTheme.first, - colorPalette = currentTheme.second, + 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) + }, ) { - - 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, - ), + Scaffold(modifier = Modifier.fillMaxSize()) { _ -> + Box( + modifier = + Modifier + .fillMaxSize() + .statusBarsPadding() + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, ), - ) { - 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() }, - ) - } + ), + ) { + 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/extensions/KoinExtensions.kt b/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt index dee5304..5925c8a 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/extensions/KoinExtensions.kt @@ -6,105 +6,9 @@ import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import org.koin.compose.LocalKoinScopeContext -import org.koin.compose.navigation3.EntryProviderInstaller import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinInternalApi -import org.koin.core.definition.KoinDefinition -import org.koin.core.module.KoinDslMarker -import org.koin.core.module.Module -import org.koin.core.module._scopedInstanceFactory -import org.koin.core.module._singleInstanceFactory -import org.koin.core.qualifier.named import org.koin.core.scope.Scope -import org.koin.dsl.ScopeDSL -import net.frozendevelopment.openletters.extensions.navigation - -/** - * Declares a scoped navigation entry within a Koin scope DSL. - * - * This function registers a composable navigation destination that is scoped to a specific Koin scope, - * allowing access to scoped dependencies within the composable. The route type [T] is used as both - * the navigation destination identifier and a qualifier for the entry provider. - * - * Example usage: - * ```kotlin - * activityScope { - * viewModel { MyViewModel() } - * navigation { route -> - * MyScreen(viewModel = koinViewModel()) - * } - * } - * ``` - * - * @param T The type representing the navigation route/destination - * @param definition A composable function that receives the [Scope] and route instance [T] to render the destination - * @return A [KoinDefinition] for the created [EntryProviderInstaller] - * - * @see Module.navigation for module-level navigation entries - */ -@KoinExperimentalAPI -@KoinDslMarker -@OptIn(KoinInternalApi::class) -inline fun ScopeDSL.navigation( - metadata: Map = emptyMap(), - noinline definition: @Composable Scope.(T) -> Unit, -): KoinDefinition { - val def = _scopedInstanceFactory(named(), { - val scope = this - { - entry( - metadata = metadata, - content = { t -> definition(scope, t) } - ) - } - }, scopeQualifier) - module.indexPrimaryType(def) - return KoinDefinition(module, def) -} - -/** - * Declares a singleton navigation entry within a Koin module. - * - * This function registers a composable navigation destination as a singleton in the Koin module, - * allowing access to module-level dependencies within the composable. The route type [T] is used - * as both the navigation destination identifier and a qualifier for the entry provider. - * - * Example usage: - * ```kotlin - * module { - * viewModel { MyViewModel() } - * navigation { route -> - * HomeScreen(myViewModel = koinViewModel()) - * } - * } - * ``` - * - * @param T The type representing the navigation route/destination - * @param definition A composable function that receives the [Scope] and route instance [T] to render the destination - * @return A [KoinDefinition] for the created [EntryProviderInstaller] - * - * @see ScopeDSL.navigation for scope-level navigation entries - */ -@KoinExperimentalAPI -@KoinDslMarker -@OptIn(KoinInternalApi::class) -inline fun Module.navigation( - metadata: Map = emptyMap(), - noinline definition: @Composable Scope.(T) -> Unit, -): KoinDefinition { - val def = _singleInstanceFactory(named(), { - val scope = this - { - entry( - metadata = metadata, - content = { t -> definition(scope, t) } - ) - } - }) - indexPrimaryType(def) - return KoinDefinition(this, def) -} - typealias EntryProvider = (NavKey) -> NavEntry 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 684efdb..f113947 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 @@ -51,7 +51,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation +import org.koin.dsl.navigation3.navigation @Serializable data class CategoryFormDestination( 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 8b799e4..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 @@ -52,7 +52,6 @@ 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.extensions.navigation 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 @@ -61,6 +60,7 @@ 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 : NavKey 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 b46ee83..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 @@ -27,7 +27,7 @@ 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.SupportingPaneSceneStrategy +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,21 +54,20 @@ 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.extensions.navigation 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.ListDetailScene.Companion.detailPane 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( @@ -77,7 +76,7 @@ data class LetterDetailDestination( @OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) fun Module.letterDetailNavigation() = navigation( - metadata = SupportingPaneSceneStrategy.supportingPane() + metadata = ListDetailSceneStrategy.detailPane(LetterListDestination::class), ) { route -> val navigator = LocalNavigator.current val viewModel: LetterDetailViewModel = koinViewModel { parametersOf(route.letterId) } @@ -93,7 +92,7 @@ fun Module.letterDetailNavigation() = navigation( ReminderFormDestination(preselectedLetters = listOf(route.letterId)), ) }, - onBackClicked = navigator::pop, + onBackClicked = { navigator.onBackPressed() }, onImageClick = { uri -> navigator.navigate(ImageDestination(uri.toString())) }, ) } 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 6ca8fce..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 @@ -39,7 +39,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation +import org.koin.dsl.navigation3.navigation @Serializable data class ImageDestination( 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 055b4ef..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 @@ -1,8 +1,5 @@ package net.frozendevelopment.openletters.feature.letter.list -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -10,7 +7,7 @@ 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.SupportingPaneSceneStrategy +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope @@ -20,35 +17,31 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark 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.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId -import net.frozendevelopment.openletters.extensions.navigation 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.ListDetailScene.Companion.listPane 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 : NavKey @OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) fun Module.letterListNavigation() = navigation( - metadata = NavDisplay.transitionSpec { - EnterTransition.None togetherWith ExitTransition.None - } + SupportingPaneSceneStrategy.mainPane() + metadata = ListDetailSceneStrategy.listPane(LetterListDestination::class), ) { route -> val drawerState = LocalDrawerState.current val navigator = LocalNavigator.current 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 0fd04e0..3890f38 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 @@ -72,7 +72,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation +import org.koin.dsl.navigation3.navigation import java.time.LocalDateTime @Serializable 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 f4ea1e7..25f6767 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 @@ -28,6 +28,8 @@ 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 @@ -54,13 +56,14 @@ 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 net.frozendevelopment.openletters.extensions.navigation +import org.koin.dsl.navigation3.navigation @Serializable data class ReminderDetailDestination( @@ -71,8 +74,10 @@ data class ReminderDetailDestination( } } -@OptIn(KoinExperimentalAPI::class) -fun Module.reminderDetailNavigation() = navigation { route -> +@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() 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 cb633ed..065ca95 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 @@ -69,7 +69,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation +import org.koin.dsl.navigation3.navigation import java.text.SimpleDateFormat import java.util.Date import java.util.Locale 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 da4bf4c..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 @@ -28,6 +28,8 @@ 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 @@ -47,7 +49,6 @@ 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.navigation import net.frozendevelopment.openletters.extensions.openAppSettings import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination @@ -59,11 +60,12 @@ 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 : NavKey -@OptIn(KoinExperimentalAPI::class) +@OptIn(KoinExperimentalAPI::class, ExperimentalMaterial3AdaptiveApi::class) fun Module.reminderListNavigation() = navigation( metadata = NavDisplay.transitionSpec { EnterTransition.None togetherWith ExitTransition.None @@ -71,7 +73,7 @@ fun Module.reminderListNavigation() = navigation( 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 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 832b94a..033c456 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 @@ -38,7 +38,7 @@ 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 net.frozendevelopment.openletters.extensions.navigation +import org.koin.dsl.navigation3.navigation @Serializable data object SettingsDestination : NavKey diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt deleted file mode 100644 index 38ca267..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/ListDetailScene.kt +++ /dev/null @@ -1,101 +0,0 @@ -package net.frozendevelopment.openletters.ui.navigation - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.scene.Scene -import androidx.navigation3.scene.SceneStrategy -import androidx.navigation3.scene.SceneStrategyScope -import androidx.navigation3.ui.NavDisplay -import androidx.window.core.layout.WindowSizeClass -import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND - -class ListDetailScene( - override val key: Any, - override val previousEntries: List>, - val listEntry: NavEntry, - val detailEntry: NavEntry, -) : Scene { - override val entries: List> = listOf(listEntry, detailEntry) - override val content: @Composable (() -> Unit) = { - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.weight(0.4f)) { - listEntry.Content() - } - Column(modifier = Modifier.weight(0.6f)) { - AnimatedContent( - targetState = detailEntry, - contentKey = { entry -> entry.contentKey }, - transitionSpec = { - slideInHorizontally( - initialOffsetX = { it } - ) togetherWith - slideOutHorizontally(targetOffsetX = { -it }) - } - ) { entry -> - entry.Content() - } - } - } - } - - companion object { - internal const val LIST_KEY = "ListDetailScene-List" - internal const val DETAIL_KEY = "ListDetailScene-Detail" - - fun listPane() = mapOf(LIST_KEY to true) + NavDisplay.transitionSpec { - EnterTransition.None togetherWith ExitTransition.None - } + NavDisplay.popTransitionSpec { - EnterTransition.None togetherWith ExitTransition.None - } + NavDisplay.predictivePopTransitionSpec { - EnterTransition.None togetherWith ExitTransition.None - } - fun detailPane() = mapOf(DETAIL_KEY to true) + NavDisplay.transitionSpec { - EnterTransition.None togetherWith ExitTransition.None - } + NavDisplay.popTransitionSpec { - EnterTransition.None togetherWith ExitTransition.None - } + NavDisplay.predictivePopTransitionSpec { - EnterTransition.None togetherWith ExitTransition.None - } - } -} - -@Composable -fun rememberListDetailSceneStrategy(): ListDetailSceneStrategy { - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - - return remember(windowSizeClass) { - ListDetailSceneStrategy(windowSizeClass) - } -} - -class ListDetailSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy { - override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { - if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { - return null - } - - val detailEntry = entries.lastOrNull()?.takeIf { it.metadata.containsKey(ListDetailScene.DETAIL_KEY) } ?: return null - val listEntry = entries.findLast { it.metadata.containsKey(ListDetailScene.LIST_KEY) } ?: return null - - val sceneKey = listEntry.contentKey - - return ListDetailScene( - sceneKey, - entries.dropLast(1), - listEntry, - detailEntry - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt deleted file mode 100644 index d5b26bf..0000000 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/NavHost.kt +++ /dev/null @@ -1,55 +0,0 @@ -package net.frozendevelopment.openletters.ui.navigation - -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.ui.NavDisplay -import net.frozendevelopment.openletters.extensions.EntryProvider -import net.frozendevelopment.openletters.extensions.koinEntryProvider -import org.koin.core.annotation.KoinExperimentalAPI - -@OptIn(KoinExperimentalAPI::class) -@Composable -fun NavHost( - startRoute: NavKey, - topLevelRoutes: Set, - entryProvider: EntryProvider = koinEntryProvider(), -) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - val navigationState = rememberNavigationState(startRoute, topLevelRoutes) - val navigator = remember { Navigator(navigationState) } - - NavHost( - drawerState = drawerState, - navigationState = navigationState, - navigator = navigator, - ) { - NavDisplay( - entries = navigationState.toEntries(entryProvider), - onBack = { navigator.pop() }, - ) - } -} - -@Composable -fun NavHost( - drawerState: DrawerState, - navigationState: NavigationState, - navigator: NavigatorType, - content: @Composable () -> Unit, -) { - CompositionLocalProvider(LocalDrawerState provides drawerState) { - CompositionLocalProvider(LocalNavigationState provides navigationState) { - CompositionLocalProvider( - LocalNavigator provides navigator, - ) { - content() - } - } - } -} 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 index 2bb9ca7..77d143f 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt @@ -1,5 +1,6 @@ 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 @@ -14,12 +15,13 @@ interface NavigatorType { fun pop() - fun popUpTo(route: NavKey) + fun onBackPressed() } @Stable class Navigator( val state: NavigationState, + val backPressedDispatcher: OnBackPressedDispatcher? = null, ) : NavigatorType { override fun navigate(route: NavKey) { if (route in state.backStacks.keys) { @@ -48,12 +50,8 @@ class Navigator( } } - override fun popUpTo(route: NavKey) { - val currentStack = state.backStacks[state.topLevelRoute] ?: error("No back stack for current route") - currentStack.indexOfLast { it == route }.takeIf { it != -1 }?.let { index -> - val itemsToRemove = currentStack.subList(index, currentStack.size) - currentStack.removeAll(itemsToRemove) - } + override fun onBackPressed() { + backPressedDispatcher?.onBackPressed() } } @@ -64,5 +62,5 @@ class PreviewNavigator : NavigatorType { override fun pop() {} - override fun popUpTo(route: NavKey) {} + override fun onBackPressed() {} } 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 index 8dc3032..089d60a 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/preview/PreviewContainer.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/preview/PreviewContainer.kt @@ -2,14 +2,8 @@ package net.frozendevelopment.openletters.ui.preview import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.DrawerValue import androidx.compose.material3.Surface -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable -import androidx.navigation3.runtime.NavKey -import net.frozendevelopment.openletters.ui.navigation.NavHost -import net.frozendevelopment.openletters.ui.navigation.PreviewNavigator -import net.frozendevelopment.openletters.ui.navigation.rememberNavigationState import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme @Composable @@ -24,13 +18,7 @@ fun PreviewContainer( dynamicColor = dynamicColor, ) { Surface { - NavHost( - drawerState = rememberDrawerState(DrawerValue.Closed), - navigationState = rememberNavigationState(object : NavKey {}, setOf()), - navigator = PreviewNavigator(), - ) { - content() - } + content() } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbcea33..5f1b98e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,16 @@ [versions] -agp = "8.13.1" -kotlin = "2.2.21" +agp = "8.13.2" +kotlin = "2.3.0" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.0" -composeBom = "2025.11.01" -navigationCommonKtx = "2.9.6" +activityCompose = "1.12.2" +composeBom = "2025.12.01" splashscreen = "1.2.0" -ksp = "2.3.3" -koin = "4.2.0-alpha2" +ksp = "2.3.4" +koin = "4.2.0-beta2" koin-annotations = "2.3.1" lifecycleRuntimeComposeAndroid = "2.10.0" sqldelight = "2.2.1" @@ -23,11 +22,11 @@ colorpicker = "1.1.3" serialization = "1.9.0" adaptiveAndroid = "1.2.0" coreAnimation = "1.0.0" -uiTextGoogleFonts = "1.9.5" +uiTextGoogleFonts = "1.10.0" datastoreCoreAndroid = "1.2.0" ktlint = "13.1.0" robolectric = "4.16" -mockk = "1.14.6" +mockk = "1.14.7" nav3Core = "1.0.0" [libraries] From b32c9a1ff77476a95f8ae0c763aef56bea5cc53d Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 22 Dec 2025 14:53:40 -0500 Subject: [PATCH 5/6] fixed tests --- .../data/sqldelight/SqlDelightKoin.kt | 53 +---------------- .../extensions/OpenLettersDBExtensions.kt | 58 +++++++++++++++++++ .../openletters/util/TestDatabase.kt | 11 ++-- 3 files changed, 65 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/net/frozendevelopment/openletters/extensions/OpenLettersDBExtensions.kt 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 ecea181..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 @@ -3,18 +3,7 @@ package net.frozendevelopment.openletters.data.sqldelight 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 net.frozendevelopment.openletters.extensions.invoke import org.koin.android.ext.koin.androidContext import org.koin.dsl.module // import org.koin.core.annotation.Factory @@ -108,45 +97,7 @@ val sqlDelightKoinModule = single { // Enable foreign keys on the driver instance used by the DB - val driver = get().apply { execute(null, "PRAGMA foreign_keys = ON;", 0) } - 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, - ), - ) + OpenLettersDB(get()) } factory { get().reminderQueries } 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/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) } From 36ccac1b54db86208c1a1b49316504f646526e85 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 22 Dec 2025 15:07:09 -0500 Subject: [PATCH 6/6] a few last minute changes --- .../openletters/MainActivity.kt | 18 +++++++++++++++++- .../feature/category/form/CategoryFormView.kt | 4 ++-- .../feature/letter/scan/ScanLetterView.kt | 4 ++-- .../reminder/detail/ReminderDetailView.kt | 2 +- .../feature/reminder/form/ReminderFormView.kt | 4 ++-- .../feature/settings/SettingsView.kt | 4 ++-- .../openletters/ui/navigation/Navigator.kt | 7 +++++++ 7 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt index cc7013d..8eec897 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt @@ -1,6 +1,9 @@ package net.frozendevelopment.openletters +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 @@ -27,6 +30,7 @@ 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.navigation3.runtime.NavKey @@ -85,7 +89,19 @@ class MainActivity : ComponentActivity() { ReminderListDestination, ), ) - val navigator = remember { Navigator(navigationState, onBackPressedDispatcher) } + 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() 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 f113947..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 @@ -80,11 +80,11 @@ fun Module.categoryFormNavigation() = navigation { rout state = state, onLabelChanged = viewModel::setLabel, onColorChanged = viewModel::setColor, - onBackClicked = navigator::pop, + onBackClicked = navigator::onBackPressed, onSaveClicked = { coroutineScope.launch { viewModel.save() - navigator.pop() + navigator.onBackPressed() } }, ) 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 3890f38..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 @@ -169,7 +169,7 @@ fun Module.scanLetterNavigation() = navigation { route -> if (viewModel.save()) { withContext(Dispatchers.Main) { if (route.canNavigateBack) { - navigator.pop() + navigator.onBackPressed() } else { navigator.navigate { backStack -> backStack.add(0, LetterListDestination) @@ -180,7 +180,7 @@ fun Module.scanLetterNavigation() = navigation { route -> } } }, - onBackClicked = navigator::pop, + onBackClicked = navigator::onBackPressed, onDeleteDocumentClicked = viewModel::removeDocument, onCreateCategoryClicked = { navigator.navigate(CategoryFormDestination(CategoryFormDestination.Mode.Create)) }, ) 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 25f6767..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 @@ -98,7 +98,7 @@ fun Module.reminderDetailNavigation() = navigation( ReminderDetailScreen( modifier = Modifier.fillMaxWidth(), state = state, - onBackClicked = navigator::pop, + onBackClicked = navigator::onBackPressed, onAcknowledgeClicked = viewModel::acknowledge, onLetterClicked = { navigator.navigate(LetterDetailDestination(it)) }, ) 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 065ca95..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 @@ -113,11 +113,11 @@ fun Module.reminderFormNavigation() = navigation { rout toggleLetterSelect = viewModel::toggleLetterSelect, onLetterClicked = { navigator.navigate(LetterDetailDestination(it)) }, openDialog = viewModel::openDialog, - onBackClicked = navigator::pop, + onBackClicked = navigator::onBackPressed, onSaveClicked = { coroutineScope.launch { if (viewModel.save()) { - navigator.pop() + navigator.onBackPressed() } } }, 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 033c456..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 @@ -53,10 +53,10 @@ fun Module.settingsNavigation() = navigation { route -> SettingsView( modifier = Modifier.fillMaxSize(), state = state, - onBackClicked = navigator::pop, + onBackClicked = navigator::onBackPressed, onThemeChanged = viewModel::setTheme, onColorVariantChanged = viewModel::setVariant, - onViewSourceClicked = {}, // { navigator.openUrl("https://github.com/frozenjava/OpenLetters") }, + onViewSourceClicked = { navigator.openUrl("https://github.com/frozenjava/OpenLetters") }, ) } } 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 index 77d143f..5c4fdc7 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt @@ -16,12 +16,15 @@ interface NavigatorType { 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) { @@ -53,6 +56,8 @@ class Navigator( override fun onBackPressed() { backPressedDispatcher?.onBackPressed() } + + override fun openUrl(url: String) = openInBrowser(url) } class PreviewNavigator : NavigatorType { @@ -63,4 +68,6 @@ class PreviewNavigator : NavigatorType { override fun pop() {} override fun onBackPressed() {} + + override fun openUrl(url: String) {} }