From 27046cb1b740ca35a47f8c1c296b2446f50ea667 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:24:32 +0200 Subject: [PATCH] Back button: fixed destinations --- .../java/org/mydomain/myscan/MainActivity.kt | 24 +++---- .../java/org/mydomain/myscan/MainViewModel.kt | 12 ++-- .../java/org/mydomain/myscan/Navigation.kt | 41 +++++++++-- .../org/mydomain/myscan/view/CameraScreen.kt | 2 +- .../org/mydomain/myscan/NavigationTest.kt | 70 +++++++++++++++++++ 5 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 app/src/test/java/org/mydomain/myscan/NavigationTest.kt diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index e96712a..eda5e69 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -64,15 +64,15 @@ class MainActivity : ComponentActivity() { val cameraPermission = rememberCameraPermissionState() MyScanTheme { val navigation = Navigation( - toHomeScreen = { viewModel.navigateTo(Screen.Home) }, - toCameraScreen = { viewModel.navigateTo(Screen.Camera) }, - toDocumentScreen = { viewModel.navigateTo(Screen.Document()) }, - toAboutScreen = { viewModel.navigateTo(Screen.About) }, - toLibrariesScreen = { viewModel.navigateTo(Screen.Libraries) }, + toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) }, + toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) }, + toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) }, + toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) }, + toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) }, back = { viewModel.navigateBack() } ) when (val screen = currentScreen) { - is Screen.Home -> { + is Screen.Main.Home -> { val recentDocs by viewModel.recentDocuments.collectAsStateWithLifecycle() HomeScreen( cameraPermission = cameraPermission, @@ -83,17 +83,17 @@ class MainActivity : ComponentActivity() { onOpenPdf = { file -> openPdf(file.toUri()) } ) } - is Screen.Camera -> { + is Screen.Main.Camera -> { CameraScreen( viewModel, navigation, liveAnalysisState, onImageAnalyzed = { image -> viewModel.liveAnalysis(image) }, - onFinalizePressed = { viewModel.navigateTo(Screen.Document()) }, + onFinalizePressed = navigation.toDocumentScreen, cameraPermission = cameraPermission ) } - is Screen.Document -> { + is Screen.Main.Document -> { DocumentScreen ( document = document, initialPage = screen.initialPage, @@ -109,14 +109,14 @@ class MainActivity : ComponentActivity() { ), onStartNew = { viewModel.startNewDocument() - viewModel.navigateTo(Screen.Home) }, + viewModel.navigateTo(Screen.Main.Home) }, onDeleteImage = { id -> viewModel.deletePage(id) } ) } - is Screen.About -> { + is Screen.Overlay.About -> { AboutScreen(onBack = navigation.back, onViewLibraries = navigation.toLibrariesScreen) } - is Screen.Libraries -> { + is Screen.Overlay.Libraries -> { LibrariesScreen(onBack = navigation.back) } } diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index 276bb46..9768696 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -73,9 +73,9 @@ class MainViewModel( val liveAnalysisState: StateFlow = _liveAnalysisState.asStateFlow() private var lastSuccessfulLiveAnalysisState: LiveAnalysisState? = null - private val _screenStack = MutableStateFlow>(listOf(Screen.Home)) - val currentScreen: StateFlow = _screenStack.map { it.last() } - .stateIn(viewModelScope, SharingStarted.Eagerly, _screenStack.value.last()) + private val _navigationState = MutableStateFlow(NavigationState.initial()) + val currentScreen: StateFlow = _navigationState.map { it.current } + .stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current) private val _pageIds = MutableStateFlow(imageRepository.imageIds()) val documentUiModel: StateFlow = @@ -159,12 +159,12 @@ class MainViewModel( } } - fun navigateTo(screen: Screen) { - _screenStack.update { it + screen } + fun navigateTo(destination: Screen) { + _navigationState.update { it.navigateTo(destination) } } fun navigateBack() { - _screenStack.update { stack -> if (stack.size > 1) stack.dropLast(1) else stack } + _navigationState.update { stack -> stack.navigateBack() } } fun onImageCaptured(imageProxy: ImageProxy?) { diff --git a/app/src/main/java/org/mydomain/myscan/Navigation.kt b/app/src/main/java/org/mydomain/myscan/Navigation.kt index 693fd1d..c5da89a 100644 --- a/app/src/main/java/org/mydomain/myscan/Navigation.kt +++ b/app/src/main/java/org/mydomain/myscan/Navigation.kt @@ -15,11 +15,15 @@ package org.mydomain.myscan sealed class Screen { - object Home : Screen() - object Camera : Screen() - data class Document(val initialPage: Int = 0) : Screen() - object About : Screen() - object Libraries : Screen() + sealed class Main : Screen() { + object Home : Main() + object Camera : Main() + data class Document(val initialPage: Int = 0) : Main() + } + sealed class Overlay : Screen() { + object About : Overlay() + object Libraries : Overlay() + } } data class Navigation( @@ -30,3 +34,30 @@ data class Navigation( val toLibrariesScreen: () -> Unit, val back: () -> Unit, ) + +@ConsistentCopyVisibility +data class NavigationState private constructor(val stack: List) { + + companion object { + fun initial() = NavigationState(listOf(Screen.Main.Home)) + } + + val current: Screen get() = stack.last() + + fun navigateTo(destination: Screen): NavigationState { + return if (destination is Screen.Overlay) { + copy(stack = stack + destination) + } else { + copy(stack = listOf(destination)) + } + } + + fun navigateBack(): NavigationState { + return when (current) { + is Screen.Main.Home -> this // Back handled by system + is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home)) + is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera)) + is Screen.Overlay -> copy(stack = stack.dropLast(1)) + } + } +} diff --git a/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt b/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt index 9f82f89..4e3c60a 100644 --- a/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt +++ b/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt @@ -154,7 +154,7 @@ fun CameraScreen( pageListState = CommonPageListState( document = document, - onPageClick = { index -> viewModel.navigateTo(Screen.Document(index)) }, + onPageClick = { index -> viewModel.navigateTo(Screen.Main.Document(index)) }, listState = listState, onLastItemPosition = { offset -> thumbnailCoords.value = offset }, ), diff --git a/app/src/test/java/org/mydomain/myscan/NavigationTest.kt b/app/src/test/java/org/mydomain/myscan/NavigationTest.kt new file mode 100644 index 0000000..6b13453 --- /dev/null +++ b/app/src/test/java/org/mydomain/myscan/NavigationTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Pierre-Yves Nicolas + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.mydomain.myscan + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mydomain.myscan.Screen.Main.Camera +import org.mydomain.myscan.Screen.Main.Document +import org.mydomain.myscan.Screen.Main.Home +import org.mydomain.myscan.Screen.Overlay.About +import org.mydomain.myscan.Screen.Overlay.Libraries + +class NavigationTest { + + @Test + fun empty_ScreenStack() { + val empty = NavigationState.initial() + assertThat(empty.current).isEqualTo(Home) + assertThat(empty.navigateBack()).isEqualTo(empty) + } + + @Test + fun navigate_between_fixed_screens() { + val atHome = NavigationState.initial() + val atCamera = atHome.navigateTo(Camera) + val atDocument = atHome.navigateTo(Document()) + + assertThat(atHome.current).isEqualTo(Home) + assertThat(atCamera.current).isEqualTo(Camera) + assertThat(atDocument.current).isEqualTo(Document()) + + assertThat(atCamera.navigateTo(Document())).isEqualTo(atDocument) + assertThat(atDocument.navigateTo(Home)).isEqualTo(atHome) + assertThat(atDocument.navigateTo(Camera)).isEqualTo(atCamera) + + assertThat(atHome.navigateBack()).isEqualTo(atHome) + assertThat(atCamera.navigateBack()).isEqualTo(atHome) + assertThat(atDocument.navigateBack()).isEqualTo(atCamera) + } + + @Test + fun navigate_to_secondary_screens() { + val atHome = NavigationState.initial() + val atCamera = atHome.navigateTo(Camera) + + val atAboutAfterHome = atHome.navigateTo(About) + assertThat(atAboutAfterHome.current).isEqualTo(About) + assertThat(atAboutAfterHome.navigateBack()).isEqualTo(atHome) + + val atAboutAfterCamera = atCamera.navigateTo(About) + assertThat(atAboutAfterCamera.current).isEqualTo(About) + assertThat(atAboutAfterCamera.navigateBack()).isEqualTo(atCamera) + + val atLibrariesAfterCameraAbout = atAboutAfterCamera.navigateTo(Libraries) + assertThat(atLibrariesAfterCameraAbout.current).isEqualTo(Libraries) + assertThat(atLibrariesAfterCameraAbout.navigateBack()).isEqualTo(atAboutAfterCamera) + } +} \ No newline at end of file