diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 677e3f9..d98d80e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -8,7 +8,6 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) - alias(libs.plugins.kotlinSerialization) alias(libs.plugins.buildconfig) alias(libs.plugins.ksp) } @@ -95,8 +94,6 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(compose.materialIconsExtended) - implementation(libs.jetbrains.navigation.compose) - implementation(libs.jetbrains.navigation.runtime) implementation(libs.jetbrains.lifecycle.viewmodel.compose) implementation(libs.multipaz) @@ -111,6 +108,7 @@ kotlin { implementation(libs.ktor.network) implementation(libs.compottie) implementation(libs.semver) + implementation(libs.bundles.nav3) } } commonTest.dependencies { @@ -183,5 +181,3 @@ tasks.withType().all { tasks["compileKotlinIosX64"].dependsOn("kspCommonMainKotlinMetadata") tasks["compileKotlinIosArm64"].dependsOn("kspCommonMainKotlinMetadata") tasks["compileKotlinIosSimulatorArm64"].dependsOn("kspCommonMainKotlinMetadata") - - diff --git a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/App.kt b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/App.kt index 536572e..602dce2 100644 --- a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/App.kt +++ b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/App.kt @@ -13,10 +13,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -249,38 +248,62 @@ class App( } } - val navController = rememberNavController() - AppTheme { - PromptDialogs(Platform.promptModel) + val startRoute = startDestination ?: StartDestination + val navigationState = rememberNavigationState( + startRoute = startRoute, + topLevelRoutes = setOf(StartDestination, SelectRequestDestination) + ) + val navigator = remember { Navigator(navigationState) } - if (BuildConfig.IDENTITY_READER_REQUIRE_TOS_ACCEPTANCE) { - val tosAgreedTo = settingsModel.tosAgreedTo.collectAsState() - if (!tosAgreedTo.value) { - TosScreen(settingsModel = settingsModel) - return@AppTheme - } - } + val selectedQueryNameState = settingsModel.selectedQueryName.collectAsState() + val devModeState = settingsModel.devMode.collectAsState() - NavHost( - navController = navController, - startDestination = startDestination ?: StartDestination, - modifier = Modifier.fillMaxSize() - ) { - composable { backStackEntry -> - StartScreen( - settingsModel = settingsModel, - readerBackendClient = readerBackendClient, - promptModel = promptModel, - mdocTransportOptionsForNfcEngagement = getMdocTransportOptionsForNfcEngagement(), - onScanQrClicked = { - navController.navigate(route = ScanQrDestination) - }, - onNfcHandover = { scanResult -> + val entryProvider = entryProvider { + entry { + StartScreen( + settingsModel = settingsModel, + readerBackendClient = readerBackendClient, + promptModel = promptModel, + mdocTransportOptionsForNfcEngagement = getMdocTransportOptionsForNfcEngagement(), + onScanQrClicked = { + navigator.navigate(ScanQrDestination) + }, + onNfcHandover = { scanResult -> + readerModel.reset() + readerModel.setConnectionEndpoint( + encodedDeviceEngagement = scanResult.encodedDeviceEngagement, + handover = scanResult.handover, + existingTransport = scanResult.transport + ) + val readerQuery = ReaderQuery.valueOf(settingsModel.selectedQueryName.value) + readerModel.setDeviceRequest( + readerQuery.generateDeviceRequest( + settingsModel = settingsModel, + encodedSessionTranscript = readerModel.encodedSessionTranscript, + readerBackendClient = readerBackendClient + ) + ) + navigator.navigate(TransferDestination) + }, + onReaderIdentityClicked = { navigator.navigate(ReaderIdentityDestination) }, + onTrustedIssuersClicked = { navigator.navigate(TrustedIssuersDestination) }, + onDeveloperSettingsClicked = { navigator.navigate(DeveloperSettingsDestination) }, + onAboutClicked = { navigator.navigate(AboutDestination) }, + ) + } + entry { + ScanQrScreen( + onBackPressed = { navigator.goBack() }, + onMdocQrCodeScanned = { mdocUri -> + coroutineScope.launch { + val encodedDeviceEngagement = + ByteString(mdocUri.substringAfter("mdoc:").fromBase64Url()) readerModel.reset() + readerModel.setMdocTransportOptions(getMdocTransportOptionsForQrEngagement()) readerModel.setConnectionEndpoint( - encodedDeviceEngagement = scanResult.encodedDeviceEngagement, - handover = scanResult.handover, - existingTransport = scanResult.transport + encodedDeviceEngagement = encodedDeviceEngagement, + handover = Simple.NULL, + existingTransport = null ) val readerQuery = ReaderQuery.valueOf(settingsModel.selectedQueryName.value) readerModel.setDeviceRequest( @@ -290,203 +313,185 @@ class App( readerBackendClient = readerBackendClient ) ) - navController.navigate(route = TransferDestination) - }, - onReaderIdentityClicked = { navController.navigate(route = ReaderIdentityDestination) }, - onTrustedIssuersClicked = { navController.navigate(route = TrustedIssuersDestination) }, - onDeveloperSettingsClicked = { navController.navigate(route = DeveloperSettingsDestination) }, - onAboutClicked = { navController.navigate(route = AboutDestination) }, - ) - } - composable { backStackEntry -> - ScanQrScreen( - onBackPressed = { navController.navigateUp() }, - onMdocQrCodeScanned = { mdocUri -> - coroutineScope.launch { - val encodedDeviceEngagement = - ByteString(mdocUri.substringAfter("mdoc:").fromBase64Url()) - readerModel.reset() - readerModel.setMdocTransportOptions(getMdocTransportOptionsForQrEngagement()) - readerModel.setConnectionEndpoint( - encodedDeviceEngagement = encodedDeviceEngagement, - handover = Simple.NULL, - existingTransport = null - ) - val readerQuery = ReaderQuery.valueOf(settingsModel.selectedQueryName.value) - readerModel.setDeviceRequest( - readerQuery.generateDeviceRequest( - settingsModel = settingsModel, - encodedSessionTranscript = readerModel.encodedSessionTranscript, - readerBackendClient = readerBackendClient - ) - ) - navController.popBackStack() - navController.navigate(route = TransferDestination) - } - } - ) - } - composable { backStackEntry -> - SelectRequestScreen( - readerModel = readerModel, - settingsModel = settingsModel, - readerBackendClient = readerBackendClient, - onBackPressed = { urlLaunchData?.finish() ?: navController.navigateUp() }, - onContinueClicked = { - navController.popBackStack() - navController.navigate(route = TransferDestination) - }, - onReaderIdentitiesClicked = { - navController.navigate(route = ReaderIdentityDestination) - } - ) - } - composable { backStackEntry -> - TransferScreen( - readerModel = readerModel, - onBackPressed = { urlLaunchData?.finish() ?: navController.navigateUp() }, - onTransferComplete = { - navController.popBackStack() - navController.navigate(route = ShowResultsDestination) + navigator.popBackStack() + navigator.navigate(TransferDestination) } - ) - } - composable { - ShowResultsScreen( - readerQuery = ReaderQuery.valueOf(settingsModel.selectedQueryName.value), - readerModel = readerModel, - documentTypeRepository = documentTypeRepository, - issuerTrustManager = compositeTrustManager, - onBackPressed = { urlLaunchData?.finish() ?: navController.navigateUp() }, - onShowDetailedResults = if (settingsModel.devMode.value) { - { navController.navigate(route = ShowDetailedResultsDestination) } - } else { - null - } - ) - } - composable { backStackEntry -> - ShowDetailedResultsScreen( - readerQuery = ReaderQuery.valueOf(settingsModel.selectedQueryName.value), - readerModel = readerModel, - documentTypeRepository = documentTypeRepository, - issuerTrustManager = compositeTrustManager, - onBackPressed = { urlLaunchData?.finish() ?: navController.navigateUp() }, - onShowCertificateChain = { certificateChain -> - val certificateDataBase64 = Cbor.encode(certificateChain.toDataItem()).toBase64Url() - navController.navigate( - route = CertificateViewerDestination(certificateDataBase64) - ) - }, - ) - } - composable { backStackEntry -> - DeveloperSettingsScreen( - settingsModel = settingsModel, - onBackPressed = { navController.navigateUp() }, - ) - } - composable { backStackEntry -> - ReaderIdentityScreen( - promptModel = promptModel, - readerBackendClient = readerBackendClient, - settingsModel = settingsModel, - onBackPressed = { navController.navigateUp() }, - onShowCertificateChain = { certificateChain -> - val certificateDataBase64 = Cbor.encode(certificateChain.toDataItem()).toBase64Url() - navController.navigate( - route = CertificateViewerDestination(certificateDataBase64) - ) - }, - ) - } - composable { backStackEntry -> - TrustedIssuersScreen( - builtInTrustManager = builtInTrustManager, - userTrustManager = userTrustManager, - settingsModel = settingsModel, - onBackPressed = { navController.navigateUp() }, - onTrustEntryClicked = { trustManagerId, entryIndex, justImported -> - navController.navigate( - route = TrustEntryViewerDestination( - trustManagerId = trustManagerId, - entryIndex = entryIndex, - justImported = justImported - ) - ) - } - ) - } - composable { backStackEntry -> - AboutScreen( - onBackPressed = { navController.navigateUp() }, - ) - } - composable { backStackEntry -> - val destination = backStackEntry.toRoute() - CertificateViewerScreen( - certificateDataBase64 = destination.certificateDataBase64, - onBackPressed = { navController.navigateUp() }, - ) - } - composable { backStackEntry -> - val destination = backStackEntry.toRoute() - TrustEntryViewerScreen( - builtInTrustManager = builtInTrustManager, - userTrustManager = userTrustManager, - trustManagerId = destination.trustManagerId, - entryIndex = destination.entryIndex, - justImported = destination.justImported, - onBackPressed = { navController.navigateUp() }, - onEditPressed = { entryIndex -> - navController.navigate( - route = TrustEntryEditorDestination(entryIndex) - ) - }, - onShowVicalEntry = { trustManagerId, entryIndex, vicalCertNum -> - navController.navigate( - route = VicalEntryViewerDestination( - trustManagerId = trustManagerId, - entryIndex = entryIndex, - certificateIndex = vicalCertNum - ) - ) - }, - onShowCertificate = { certificate -> - val certificateDataBase64 = Cbor.encode(certificate.toDataItem()).toBase64Url() - navController.navigate( - route = CertificateViewerDestination(certificateDataBase64) + } + ) + } + entry { + SelectRequestScreen( + readerModel = readerModel, + settingsModel = settingsModel, + readerBackendClient = readerBackendClient, + onBackPressed = { urlLaunchData?.finish() ?: navigator.goBack() }, + onContinueClicked = { + navigator.popBackStack() + navigator.navigate(TransferDestination) + }, + onReaderIdentitiesClicked = { + navigator.navigate(ReaderIdentityDestination) + } + ) + } + entry { + TransferScreen( + readerModel = readerModel, + onBackPressed = { urlLaunchData?.finish() ?: navigator.goBack() }, + onTransferComplete = { + navigator.popBackStack() + navigator.navigate(ShowResultsDestination) + } + ) + } + entry { + ShowResultsScreen( + readerQuery = ReaderQuery.valueOf(selectedQueryNameState.value), + readerModel = readerModel, + documentTypeRepository = documentTypeRepository, + issuerTrustManager = compositeTrustManager, + onBackPressed = { urlLaunchData?.finish() ?: navigator.goBack() }, + onShowDetailedResults = if (devModeState.value) { + { navigator.navigate(ShowDetailedResultsDestination) } + } else { + null + } + ) + } + entry { + ShowDetailedResultsScreen( + readerQuery = ReaderQuery.valueOf(selectedQueryNameState.value), + readerModel = readerModel, + documentTypeRepository = documentTypeRepository, + issuerTrustManager = compositeTrustManager, + onBackPressed = { urlLaunchData?.finish() ?: navigator.goBack() }, + onShowCertificateChain = { certificateChain -> + val certificateDataBase64 = Cbor.encode(certificateChain.toDataItem()).toBase64Url() + navigator.navigate( + CertificateViewerDestination(certificateDataBase64) + ) + }, + ) + } + entry { + DeveloperSettingsScreen( + settingsModel = settingsModel, + onBackPressed = { navigator.goBack() }, + ) + } + entry { + ReaderIdentityScreen( + promptModel = promptModel, + readerBackendClient = readerBackendClient, + settingsModel = settingsModel, + onBackPressed = { navigator.goBack() }, + onShowCertificateChain = { certificateChain -> + val certificateDataBase64 = Cbor.encode(certificateChain.toDataItem()).toBase64Url() + navigator.navigate( + CertificateViewerDestination(certificateDataBase64) + ) + }, + ) + } + entry { + TrustedIssuersScreen( + builtInTrustManager = builtInTrustManager, + userTrustManager = userTrustManager, + settingsModel = settingsModel, + onBackPressed = { navigator.goBack() }, + onTrustEntryClicked = { trustManagerId, entryIndex, justImported -> + navigator.navigate( + TrustEntryViewerDestination( + trustManagerId = trustManagerId, + entryIndex = entryIndex, + justImported = justImported ) - }, - onShowCertificateChain = { certificateChain -> - val certificateDataBase64 = Cbor.encode(certificateChain.toDataItem()).toBase64Url() - navController.navigate( - route = CertificateViewerDestination(certificateDataBase64) + ) + } + ) + } + entry { + AboutScreen( + onBackPressed = { navigator.goBack() }, + ) + } + entry { key -> + CertificateViewerScreen( + certificateDataBase64 = key.certificateDataBase64, + onBackPressed = { navigator.goBack() }, + ) + } + entry { key -> + TrustEntryViewerScreen( + builtInTrustManager = builtInTrustManager, + userTrustManager = userTrustManager, + trustManagerId = key.trustManagerId, + entryIndex = key.entryIndex, + justImported = key.justImported, + onBackPressed = { navigator.goBack() }, + onEditPressed = { entryIndex -> + navigator.navigate( + TrustEntryEditorDestination(entryIndex) + ) + }, + onShowVicalEntry = { trustManagerId, entryIndex, vicalCertNum -> + navigator.navigate( + VicalEntryViewerDestination( + trustManagerId = trustManagerId, + entryIndex = entryIndex, + certificateIndex = vicalCertNum ) - }, - ) - } - composable { backStackEntry -> - val destination = backStackEntry.toRoute() - TrustEntryEditorScreen( - userTrustManager = userTrustManager, - entryIndex = destination.entryIndex, - onBackPressed = { navController.navigateUp() }, - ) - } - composable { backStackEntry -> - val destination = backStackEntry.toRoute() - VicalEntryViewerScreen( - builtInTrustManager = builtInTrustManager, - userTrustManager = userTrustManager, - trustManagerId = destination.trustManagerId, - entryIndex = destination.entryIndex, - certificateIndex = destination.certificateIndex, - onBackPressed = { navController.navigateUp() }, - ) + ) + }, + onShowCertificate = { certificate -> + val certificateDataBase64 = Cbor.encode(certificate.toDataItem()).toBase64Url() + navigator.navigate( + CertificateViewerDestination(certificateDataBase64) + ) + }, + onShowCertificateChain = { certificateChain -> + val certificateDataBase64 = Cbor.encode(certificateChain.toDataItem()).toBase64Url() + navigator.navigate( + CertificateViewerDestination(certificateDataBase64) + ) + }, + ) + } + entry { key -> + TrustEntryEditorScreen( + userTrustManager = userTrustManager, + entryIndex = key.entryIndex, + onBackPressed = { navigator.goBack() }, + ) + } + entry { key -> + VicalEntryViewerScreen( + builtInTrustManager = builtInTrustManager, + userTrustManager = userTrustManager, + trustManagerId = key.trustManagerId, + entryIndex = key.entryIndex, + certificateIndex = key.certificateIndex, + onBackPressed = { navigator.goBack() }, + ) + } + } + + AppTheme { + PromptDialogs(Platform.promptModel) + + if (BuildConfig.IDENTITY_READER_REQUIRE_TOS_ACCEPTANCE) { + val tosAgreedTo = settingsModel.tosAgreedTo.collectAsState() + if (!tosAgreedTo.value) { + TosScreen(settingsModel = settingsModel) + return@AppTheme } } + + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() }, + modifier = Modifier.fillMaxSize() + ) } } } - diff --git a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/Destinations.kt b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/Destinations.kt index 2cd6ebc..c4bb82d 100644 --- a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/Destinations.kt +++ b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/Destinations.kt @@ -1,63 +1,48 @@ package org.multipaz.identityreader -import kotlinx.serialization.Serializable +import androidx.navigation3.runtime.NavKey -@Serializable -sealed class Destination +sealed interface Destination : NavKey -@Serializable -data object StartDestination: Destination() +data object StartDestination: Destination -@Serializable -data object ScanQrDestination: Destination() +data object ScanQrDestination: Destination -@Serializable -data object SelectRequestDestination: Destination() +data object SelectRequestDestination: Destination -@Serializable -data object TransferDestination: Destination() +data object TransferDestination: Destination -@Serializable -data object ShowResultsDestination: Destination() +data object ShowResultsDestination: Destination -@Serializable -data object ShowDetailedResultsDestination: Destination() +data object ShowDetailedResultsDestination: Destination -@Serializable -data object AboutDestination: Destination() +data object AboutDestination: Destination -@Serializable data class CertificateViewerDestination( val certificateDataBase64: String -): Destination() +): Destination -@Serializable data class TrustEntryViewerDestination( val trustManagerId: String, val entryIndex: Int, val justImported: Boolean, -): Destination() +): Destination -@Serializable data class TrustEntryEditorDestination( val entryIndex: Int, -): Destination() +): Destination -@Serializable data class VicalEntryViewerDestination( val trustManagerId: String, val entryIndex: Int, val certificateIndex: Int, -): Destination() +): Destination -@Serializable -data object TrustedIssuersDestination: Destination() +data object TrustedIssuersDestination: Destination -@Serializable -data object DeveloperSettingsDestination: Destination() +data object DeveloperSettingsDestination: Destination -@Serializable -data object ReaderIdentityDestination: Destination() +data object ReaderIdentityDestination: Destination const val TRUST_MANAGER_ID_BUILT_IN = "built-in" -const val TRUST_MANAGER_ID_USER = "user" \ No newline at end of file +const val TRUST_MANAGER_ID_USER = "user" diff --git a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/NavigationHelper.kt b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/NavigationHelper.kt new file mode 100644 index 0000000..67ae31d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/NavigationHelper.kt @@ -0,0 +1,182 @@ +package org.multipaz.identityreader + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator + + +/** + * Creates and remembers a [NavigationState] instance that manages navigation state for a + * hierarchical navigation structure with top-level routes and their associated back stacks. + * Considered as a replacement for the need to depend on the rememberNavBackStack which is requiring + * the problematic in some upstream solutions org.jetbrains.kotlin.plugin.serialization plugin dependency. + * + * This composable initializes the state required to handle navigation between top-level destinations + * (like a bottom navigation bar) and the individual back stacks for each of those destinations. + * + * @param startRoute The initial top-level route to be displayed when the navigation starts. + * @param topLevelRoutes A set of all available top-level routes (keys) that form the primary + * navigation structure. Each key in this set will have its own independent + * back stack initialized. + * @return A [NavigationState] object that holds the current top-level selection and the + * history (back stack) for every defined top-level route. + */ +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set +): NavigationState { + + val topLevelRoute = remember(startRoute) { + mutableStateOf(startRoute) + } + + val backStacks = topLevelRoutes.associateWith { key -> + remember { mutableStateListOf(key) } + } + + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } +} + +/** + * A state object that can be hoisted to control and observe navigation actions. + * It manages the current navigation hierarchy, including the top-level route and the back stack + * for each top-level destination. + * + * This class should be created and remembered using the [rememberNavigationState] composable. + * + * @param startRoute The initial or home route of the navigation graph. + * @param topLevelRoute A mutable state holding the currently selected top-level route. This is often + * one of the primary destinations in a bottom navigation bar or navigation rail. + * @param backStacks A map where each key is a top-level [NavKey] and the value is a [SnapshotStateList] + * representing the back stack of destinations navigated to within that top-level route. + */ +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> +) { + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } +} + +/** + * Converts the current [NavigationState] into a list of [NavEntry] objects suitable for display. + * + * This composable function processes the back stacks associated with the navigation state. + * It applies necessary decorators (specifically [rememberSaveableStateHolderNavEntryDecorator]) + * to manage the lifecycle and saved state of the navigation entries. + * + * The resulting list represents the linear sequence of entries that should currently be + * rendered, flattening the relevant back stacks based on the current top-level route configuration. + * + * @param entryProvider A lambda that creates a [NavEntry] for a given [NavKey]. This is used to + * instantiate the actual content for each key in the back stack. + * @return A [SnapshotStateList] containing the decorated [NavEntry] objects currently in use. + */ +@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() +} + +/** + * A helper class for navigating within the application, managing both top-level switching + * and stack-based history. + * + * This class provides a high-level API for modifying the [NavigationState], abstracting away + * the details of stack management. It supports: + * - Switching between top-level routes. + * - Pushing new screens onto the current top-level stack. + * - Popping the back stack or returning to the start destination. + * + * @property state The underlying state object holding the route history and current selection. + */ +class Navigator(val state: NavigationState) { + /** + * Navigates to a specific [route]. + * + * If the [route] is one of the top-level routes (a key in the `backStacks`), + * this function switches the current top-level context to that route. + * Otherwise, it pushes the [route] onto the back stack of the currently active top-level route. + * + * @param route The destination [NavKey] to navigate to. + */ + fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + + /** + * Handles the "back" navigation action. + * + * This function attempts to navigate back in the current back stack. The behavior depends on the + * current state of the navigation stack: + * 1. If the current back stack contains more than one entry, the top-most entry is removed, + * effectively navigating to the previous screen within the current top-level route. + * 2. If the current back stack is at its root (size is 1) and the current top-level route is + * not the start route, the navigation switches back to the [state.startRoute]. + * + * @throws IllegalStateException if the back stack for the current top-level route cannot be found. + */ + fun goBack() { + val currentStack = state.backStacks[state.topLevelRoute] + ?: error("Stack for ${state.topLevelRoute} not found") + + if (currentStack.size > 1) { + currentStack.removeAt(currentStack.lastIndex) // Don't use removeLast() here. + } else if (state.topLevelRoute != state.startRoute) { + state.topLevelRoute = state.startRoute + } + } + + /** + * Pops the top destination off the back stack of the current navigation flow. + * + * This operation delegates to [goBack], attempting to remove the current screen from the stack. + * If the current stack has only one entry (the root of that stack), it may navigate back to the + * start route (home) if currently on a different top-level route. + */ + fun popBackStack() { + goBack() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/ShowResultsScreen.kt b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/ShowResultsScreen.kt index b52485e..1ca1775 100644 --- a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/ShowResultsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/ShowResultsScreen.kt @@ -251,7 +251,7 @@ private fun ShowResultsScreenFailed( Res.readBytes("files/error_animation.json").decodeToString() ) } - val errorProgress by animateLottieCompositionAsState( + val errorProgressState = animateLottieCompositionAsState( composition = errorComposition, ) @@ -269,7 +269,7 @@ private fun ShowResultsScreenFailed( Image( painter = rememberLottiePainter( composition = errorComposition, - progress = { errorProgress }, + progress = { errorProgressState.value }, ), contentDescription = null, modifier = Modifier.size(200.dp) @@ -310,15 +310,6 @@ private fun ShowResultsScreenSuccess( documents: List, onShowDetailedResults: (() -> Unit)? ) { - val successComposition by rememberLottieComposition { - LottieCompositionSpec.JsonString( - Res.readBytes("files/success_animation.json").decodeToString() - ) - } - val successProgress by animateLottieCompositionAsState( - composition = successComposition, - ) - // For now we only consider the first document... val document = documents[0] @@ -414,7 +405,7 @@ private fun ShowAgeOver( Res.readBytes(animationFile).decodeToString() ) } - val progress by animateLottieCompositionAsState( + val progressState = animateLottieCompositionAsState( composition = composition, ) @@ -440,7 +431,7 @@ private fun ShowAgeOver( Image( painter = rememberLottiePainter( composition = composition, - progress = { progress }, + progress = { progressState.value }, ), contentDescription = null, modifier = Modifier.size(50.dp) @@ -465,7 +456,7 @@ private fun ShowIdentification( Res.readBytes("files/success_animation.json").decodeToString() ) } - val progress by animateLottieCompositionAsState( + val progressState = animateLottieCompositionAsState( composition = composition, ) @@ -491,7 +482,7 @@ private fun ShowIdentification( Image( painter = rememberLottiePainter( composition = composition, - progress = { progress }, + progress = { progressState.value }, ), contentDescription = null, modifier = Modifier.size(50.dp) diff --git a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/StartScreen.kt b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/StartScreen.kt index 0e591d8..f75ecbf 100644 --- a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/StartScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/StartScreen.kt @@ -324,7 +324,7 @@ private fun StartScreenWithPermissions( } ) } - val nfcProgress by animateLottieCompositionAsState( + val nfcProgressState = animateLottieCompositionAsState( composition = nfcComposition, iterations = Compottie.IterateForever ) @@ -425,7 +425,7 @@ private fun StartScreenWithPermissions( Image( painter = rememberLottiePainter( composition = nfcComposition, - progress = { nfcProgress }, + progress = { nfcProgressState.value }, ), contentDescription = null, modifier = Modifier.size(200.dp) diff --git a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/TransferScreen.kt b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/TransferScreen.kt index 0bf59c8..41b10a3 100644 --- a/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/TransferScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/multipaz/identityreader/TransferScreen.kt @@ -37,7 +37,7 @@ fun TransferScreen( Res.readBytes("files/waiting_animation.json").decodeToString() ) } - val waitingProgress by animateLottieCompositionAsState( + val waitingProgressState = animateLottieCompositionAsState( composition = waitingComposition, iterations = Compottie.IterateForever ) @@ -77,7 +77,7 @@ fun TransferScreen( Image( painter = rememberLottiePainter( composition = waitingComposition, - progress = { waitingProgress }, + progress = { waitingProgressState.value }, ), contentDescription = null, modifier = Modifier.size(300.dp) @@ -92,4 +92,3 @@ fun TransferScreen( } } } - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c27b809..7647b40 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,6 @@ ktor = "2.3.13" logback = "1.5.21" hsqldb = "2.7.4" mysql = "8.0.33" -jetbrains-navigation = "2.9.1" jetbrains-lifecycle = "2.9.6" androidx-fragment = "1.8.9" compottie = "2.0.2" @@ -30,6 +29,7 @@ identity-googleid = "1.1.1" identity-google-api-client = "2.8.1" skie = "0.10.8" monitor = "1.8.0" +navigation3 = "1.1.0-alpha01" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -56,8 +56,6 @@ ktor-server-logging = { module = "io.ktor:ktor-server-call-logging", version.ref logback-classic = { module = "ch.qos.logback:logback-classic", version.ref="logback" } hsqldb = { module = "org.hsqldb:hsqldb", version.ref = "hsqldb" } mysql = { module = "mysql:mysql-connector-java", version.ref = "mysql" } -jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref="jetbrains-navigation" } -jetbrains-navigation-runtime = { module = "org.jetbrains.androidx.navigation:navigation-runtime", version.ref="jetbrains-navigation" } jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref="jetbrains-lifecycle" } androidx-fragment = { module = "androidx.fragment:fragment", version.ref="androidx-fragment" } compottie = { module = "io.github.alexzhirkevich:compottie", version.ref="compottie" } @@ -68,6 +66,17 @@ identity-googleid = { module = "com.google.android.libraries.identity.googleid:g identity-google-api-client = { module = "com.google.api-client:google-api-client", version.ref = "identity-google-api-client" } androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" } +# Must use the Google namespace for the runtime. +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +# Must use the JetBrains namespace for the UI/Display components. +androidx-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } + +[bundles] +nav3 = [ + "androidx-navigation3-runtime", + "androidx-navigation3-ui" +] + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } diff --git a/libfrontend/src/commonMain/kotlin/org/multipaz/identityreader/ReaderBackendClient.kt b/libfrontend/src/commonMain/kotlin/org/multipaz/identityreader/ReaderBackendClient.kt index 6b02c76..36b67ab 100644 --- a/libfrontend/src/commonMain/kotlin/org/multipaz/identityreader/ReaderBackendClient.kt +++ b/libfrontend/src/commonMain/kotlin/org/multipaz/identityreader/ReaderBackendClient.kt @@ -104,6 +104,9 @@ open class ReaderBackendClient( setBody(Json.encodeToString(request)) } val responseBody = response.body().decodeToString() + if (response.status != HttpStatusCode.OK) { + throw IllegalStateException("Error communicating with server: ${response.status} - $responseBody") + } return Pair( response.status, Json.decodeFromString(responseBody)