AboutScreen: basic version

Update navigation to work with AboutScreen
This commit is contained in:
Pierre-Yves Nicolas
2025-07-06 17:57:32 +02:00
parent 7a0309a052
commit ec35bea989
6 changed files with 181 additions and 14 deletions

View File

@@ -38,6 +38,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
import org.mydomain.myscan.view.AboutScreen
import org.mydomain.myscan.view.CameraScreen import org.mydomain.myscan.view.CameraScreen
import org.mydomain.myscan.view.DocumentScreen import org.mydomain.myscan.view.DocumentScreen
import org.opencv.android.OpenCVLoader import org.opencv.android.OpenCVLoader
@@ -57,21 +58,27 @@ class MainActivity : ComponentActivity() {
val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle() val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle()
val pageIds by viewModel.pageIds.collectAsStateWithLifecycle() val pageIds by viewModel.pageIds.collectAsStateWithLifecycle()
MyScanTheme { MyScanTheme {
val navigation = Navigation(
toCameraScreen = { viewModel.navigateTo(Screen.Camera) },
toDocumentScreen = { viewModel.navigateTo(Screen.Document()) },
toAboutScreen = { viewModel.navigateTo(Screen.About) },
back = { viewModel.navigateBack() }
)
when (val screen = currentScreen) { when (val screen = currentScreen) {
is Screen.Camera -> { is Screen.Camera -> {
CameraScreen( CameraScreen(
viewModel, viewModel,
liveAnalysisState, liveAnalysisState,
onImageAnalyzed = { image -> viewModel.liveAnalysis(image) }, onImageAnalyzed = { image -> viewModel.liveAnalysis(image) },
onFinalizePressed = { viewModel.navigateTo(Screen.FinalizeDocument()) }, onFinalizePressed = { viewModel.navigateTo(Screen.Document()) },
) )
} }
is Screen.FinalizeDocument -> { is Screen.Document -> {
DocumentScreen ( DocumentScreen (
pageIds, pageIds,
initialPage = screen.initialPage, initialPage = screen.initialPage,
imageLoader = { id -> viewModel.getBitmap(id) }, imageLoader = { id -> viewModel.getBitmap(id) },
toCameraScreen = { viewModel.navigateTo(Screen.Camera) }, navigation = navigation,
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(
startGeneration = viewModel::startPdfGeneration, startGeneration = viewModel::startPdfGeneration,
cancelGeneration = viewModel::cancelPdfGeneration, cancelGeneration = viewModel::cancelPdfGeneration,
@@ -87,6 +94,9 @@ class MainActivity : ComponentActivity() {
onDeleteImage = { id -> viewModel.deletePage(id) } onDeleteImage = { id -> viewModel.deletePage(id) }
) )
} }
is Screen.About -> {
AboutScreen(onBack = navigation.back)
}
} }
} }
} }

View File

@@ -29,10 +29,12 @@ import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -63,8 +65,9 @@ class MainViewModel(
private var _liveAnalysisState = MutableStateFlow(LiveAnalysisState()) private var _liveAnalysisState = MutableStateFlow(LiveAnalysisState())
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow() val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
private val _currentScreen = MutableStateFlow<Screen>(Screen.Camera) private val _screenStack = MutableStateFlow<List<Screen>>(listOf(Screen.Camera))
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow() val currentScreen: StateFlow<Screen> = _screenStack.map { it.last() }
.stateIn(viewModelScope, SharingStarted.Eagerly, Screen.Camera)
private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds()) private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds())
val pageIds: StateFlow<List<String>> = _pageIds val pageIds: StateFlow<List<String>> = _pageIds
@@ -135,7 +138,11 @@ class MainViewModel(
} }
fun navigateTo(screen: Screen) { fun navigateTo(screen: Screen) {
_currentScreen.value = screen _screenStack.update { it + screen }
}
fun navigateBack() {
_screenStack.update { stack -> if (stack.size > 1) stack.dropLast(1) else stack }
} }
fun onImageCaptured(imageProxy: ImageProxy?) { fun onImageCaptured(imageProxy: ImageProxy?) {

View File

@@ -16,5 +16,13 @@ package org.mydomain.myscan
sealed class Screen { sealed class Screen {
object Camera : Screen() object Camera : Screen()
data class FinalizeDocument(val initialPage: Int = 0) : Screen() data class Document(val initialPage: Int = 0) : Screen()
object About : Screen()
} }
data class Navigation(
val toCameraScreen: () -> Unit,
val toDocumentScreen: () -> Unit,
val toAboutScreen: () -> Unit,
val back: () -> Unit,
)

View File

@@ -0,0 +1,112 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.mydomain.myscan.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("About") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
}
) { paddingValues ->
AboutContent(Modifier.padding(paddingValues))
}
}
@Composable
fun AboutContent(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Text(
"MyScan",
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(8.dp))
Text(
"A simple and respectful application to scan your documents.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
Text(
"Version",
style = MaterialTheme.typography.titleSmall
)
Text("1.0.0")
Spacer(Modifier.height(16.dp))
Text(
"License",
style = MaterialTheme.typography.titleSmall
)
Text("This application is published under the GPLv3 license.")
Spacer(Modifier.height(16.dp))
Text(
"This application is based on the following open-source libraries",
style = MaterialTheme.typography.titleSmall
)
Text("• CameraX\n• Jetpack Compose\n• LiteRT\n• OpenCV\n• PDFBox")
Spacer(Modifier.height(32.dp))
}
}
@Preview
@Composable
fun AboutScreenPreview() {
AboutScreen(onBack = {})
}

View File

@@ -45,7 +45,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -146,7 +149,7 @@ fun CameraScreen(
CommonPageList( CommonPageList(
pageIds = pageIds, pageIds = pageIds,
imageLoader = { id -> viewModel.getBitmap(id) }, imageLoader = { id -> viewModel.getBitmap(id) },
onPageClick = { index -> viewModel.navigateTo(Screen.FinalizeDocument(index)) }, onPageClick = { index -> viewModel.navigateTo(Screen.Document(index)) },
listState = listState, listState = listState,
onLastItemPosition = onLastItemPosition =
{ offset -> thumbnailCoords.value = offset } { offset -> thumbnailCoords.value = offset }
@@ -170,6 +173,7 @@ fun CameraScreen(
onFinalizePressed = onFinalizePressed, onFinalizePressed = onFinalizePressed,
onDebugModeSwitched = { isDebugMode = !isDebugMode }, onDebugModeSwitched = { isDebugMode = !isDebugMode },
thumbnailCoords = thumbnailCoords, thumbnailCoords = thumbnailCoords,
toAboutScreen = { viewModel.navigateTo(Screen.About) }
) )
} }
@@ -182,6 +186,7 @@ private fun CameraScreenScaffold(
onFinalizePressed: () -> Unit, onFinalizePressed: () -> Unit,
onDebugModeSwitched: () -> Unit, onDebugModeSwitched: () -> Unit,
thumbnailCoords: MutableState<Offset>, thumbnailCoords: MutableState<Offset>,
toAboutScreen: () -> Unit,
) { ) {
Box { Box {
Scaffold( Scaffold(
@@ -199,6 +204,19 @@ private fun CameraScreenScaffold(
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
.fillMaxSize() .fillMaxSize()
) { ) {
Box(
modifier = Modifier.fillMaxSize().padding(innerPadding)
) {
IconButton(
onClick = toAboutScreen,
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "About",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
}
}
CameraPreviewWithOverlay(cameraPreview, cameraUiState, Modifier.align(Alignment.BottomCenter)) CameraPreviewWithOverlay(cameraPreview, cameraUiState, Modifier.align(Alignment.BottomCenter))
if (cameraUiState.isDebugMode) { if (cameraUiState.isDebugMode) {
MessageBox(cameraUiState.liveAnalysisState.inferenceTime) MessageBox(cameraUiState.liveAnalysisState.inferenceTime)
@@ -469,6 +487,7 @@ private fun ScreenPreview(captureState: CaptureState) {
onFinalizePressed = {}, onFinalizePressed = {},
onDebugModeSwitched = {}, onDebugModeSwitched = {},
thumbnailCoords = thumbnailCoords, thumbnailCoords = thumbnailCoords,
toAboutScreen = {}
) )
} }
} }

View File

@@ -19,6 +19,7 @@ import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -36,6 +37,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -65,6 +67,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable import net.engawapg.lib.zoomable.zoomable
import org.mydomain.myscan.Navigation
import org.mydomain.myscan.PdfGenerationActions import org.mydomain.myscan.PdfGenerationActions
import org.mydomain.myscan.ui.PdfGenerationUiState import org.mydomain.myscan.ui.PdfGenerationUiState
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
@@ -75,7 +78,7 @@ fun DocumentScreen(
pageIds: List<String>, pageIds: List<String>,
initialPage: Int, initialPage: Int,
imageLoader: (String) -> Bitmap?, imageLoader: (String) -> Bitmap?,
toCameraScreen: () -> Unit, navigation: Navigation,
pdfActions: PdfGenerationActions, pdfActions: PdfGenerationActions,
onStartNew: () -> Unit, onStartNew: () -> Unit,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
@@ -88,11 +91,11 @@ fun DocumentScreen(
currentPageIndex.intValue = pageIds.size - 1 currentPageIndex.intValue = pageIds.size - 1
} }
if (currentPageIndex.intValue < 0) { if (currentPageIndex.intValue < 0) {
toCameraScreen() navigation.toCameraScreen()
return return
} }
BackHandler { BackHandler {
toCameraScreen() navigation.back()
} }
Scaffold ( Scaffold (
topBar = { topBar = {
@@ -103,15 +106,23 @@ fun DocumentScreen(
), ),
title = { Text("Document") }, title = { Text("Document") },
navigationIcon = { navigationIcon = {
IconButton(onClick = toCameraScreen) { IconButton(onClick = navigation.toCameraScreen) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = {
IconButton(onClick = navigation.toAboutScreen) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "About",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
}
}
) )
}, },
bottomBar = { bottomBar = {
Column { Column {
PageList(pageIds, imageLoader, currentPageIndex, toCameraScreen) PageList(pageIds, imageLoader, currentPageIndex, navigation.toCameraScreen)
BottomBar(showPdfDialog, showNewDocDialog) BottomBar(showPdfDialog, showNewDocDialog)
} }
} }
@@ -280,7 +291,7 @@ fun DocumentScreenPreview() {
BitmapFactory.decodeStream(input) BitmapFactory.decodeStream(input)
} }
}, },
toCameraScreen = {}, navigation = Navigation({}, {}, {}, {}),
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(
{}, {}, {}, {}, {}, {},
MutableStateFlow(PdfGenerationUiState()), MutableStateFlow(PdfGenerationUiState()),