Home screen

This commit is contained in:
Pierre-Yves Nicolas
2025-08-19 08:27:03 +02:00
committed by pynicolas
parent e74bbcd0d6
commit 5c7d603c3e
12 changed files with 343 additions and 50 deletions

View File

@@ -40,6 +40,7 @@ import org.mydomain.myscan.ui.theme.MyScanTheme
import org.mydomain.myscan.view.AboutScreen
import org.mydomain.myscan.view.CameraScreen
import org.mydomain.myscan.view.DocumentScreen
import org.mydomain.myscan.view.HomeScreen
import org.mydomain.myscan.view.LibrariesScreen
import org.opencv.android.OpenCVLoader
@@ -61,6 +62,7 @@ class MainActivity : ComponentActivity() {
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
MyScanTheme {
val navigation = Navigation(
toHomeScreen = { viewModel.navigateTo(Screen.Home) },
toCameraScreen = { viewModel.navigateTo(Screen.Camera) },
toDocumentScreen = { viewModel.navigateTo(Screen.Document()) },
toAboutScreen = { viewModel.navigateTo(Screen.About) },
@@ -68,9 +70,18 @@ class MainActivity : ComponentActivity() {
back = { viewModel.navigateBack() }
)
when (val screen = currentScreen) {
is Screen.Home -> {
HomeScreen(
hasCameraPermission = hasCameraPermission(this),
currentDocument = document,
navigation = navigation,
onStartNewScan = navigation.toCameraScreen,
)
}
is Screen.Camera -> {
CameraScreen(
viewModel,
navigation,
liveAnalysisState,
onImageAnalyzed = { image -> viewModel.liveAnalysis(image) },
onFinalizePressed = { viewModel.navigateTo(Screen.Document()) },
@@ -92,7 +103,7 @@ class MainActivity : ComponentActivity() {
),
onStartNew = {
viewModel.startNewDocument()
viewModel.navigateTo(Screen.Camera) },
viewModel.navigateTo(Screen.Home) },
onDeleteImage = { id -> viewModel.deletePage(id) }
)
}

View File

@@ -68,9 +68,9 @@ class MainViewModel(
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
private var lastSuccessfulLiveAnalysisState: LiveAnalysisState? = null
private val _screenStack = MutableStateFlow<List<Screen>>(listOf(Screen.Camera))
private val _screenStack = MutableStateFlow<List<Screen>>(listOf(Screen.Home))
val currentScreen: StateFlow<Screen> = _screenStack.map { it.last() }
.stateIn(viewModelScope, SharingStarted.Eagerly, Screen.Camera)
.stateIn(viewModelScope, SharingStarted.Eagerly, _screenStack.value.last())
private val _pageIds = MutableStateFlow(imageRepository.imageIds())
val documentUiModel: StateFlow<DocumentUiModel> =

View File

@@ -15,6 +15,7 @@
package org.mydomain.myscan
sealed class Screen {
object Home : Screen()
object Camera : Screen()
data class Document(val initialPage: Int = 0) : Screen()
object About : Screen()
@@ -22,6 +23,7 @@ sealed class Screen {
}
data class Navigation(
val toHomeScreen: () -> Unit,
val toCameraScreen: () -> Unit,
val toDocumentScreen: () -> Unit,
val toAboutScreen: () -> Unit,

View File

@@ -0,0 +1,53 @@
/*
* 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
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
fun hasCameraPermission(context: Context): Boolean {
val camera = Manifest.permission.CAMERA
return ContextCompat.checkSelfPermission(context, camera) == PackageManager.PERMISSION_GRANTED
}
@Composable
fun rememberCameraPermissionLauncher(
onGranted: () -> Unit = {},
onDenied: () -> Unit = {}
): ManagedActivityResultLauncher<String, Boolean> {
val context = LocalContext.current
return rememberLauncherForActivityResult (
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
onGranted()
} else {
onDenied()
Toast.makeText(
context,
context.getString(R.string.camera_permission_denied),
Toast.LENGTH_SHORT
).show()
}
}
}

View File

@@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
@@ -38,13 +38,15 @@ import org.mydomain.myscan.R
fun MainActionButton(
onClick: () -> Unit,
text: String,
icon: ImageVector,
icon: ImageVector? = null,
modifier: Modifier = Modifier,
iconDescription: String? = null,
enabled: Boolean = true,
) {
Button(onClick = onClick, enabled = enabled, modifier = modifier) {
Icon(icon, contentDescription = iconDescription)
icon?.let {
Icon(icon, contentDescription = iconDescription)
}
Spacer(Modifier.width(8.dp))
Text(text)
}
@@ -93,7 +95,7 @@ fun AboutScreenNavButton(
modifier = modifier
) {
Icon(
imageVector = Icons.Outlined.Info,
imageVector = Icons.Default.Info,
contentDescription = stringResource(R.string.about),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
}

View File

@@ -14,14 +14,10 @@
*/
package org.mydomain.myscan.view
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.graphics.Bitmap
import android.util.Log
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture
@@ -58,7 +54,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.common.util.concurrent.ListenableFuture
import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.Point
import org.mydomain.myscan.R
import org.mydomain.myscan.hasCameraPermission
import org.mydomain.myscan.rememberCameraPermissionLauncher
import org.mydomain.myscan.scaledTo
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -71,18 +68,10 @@ fun CameraPreview(
onPreviewViewReady: (PreviewView) -> Unit,
) {
val context = LocalContext.current
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (!isGranted) {
Toast.makeText(context,
context.getString(R.string.camera_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val requestPermissionLauncher = rememberCameraPermissionLauncher(onGranted = {}, onDenied = {})
LaunchedEffect(Unit) {
val camera = android.Manifest.permission.CAMERA
if (ContextCompat.checkSelfPermission(context, camera) != PERMISSION_GRANTED) {
if (!hasCameraPermission(context)) {
requestPermissionLauncher.launch(camera)
}
}

View File

@@ -18,6 +18,7 @@ import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.camera.core.ImageProxy
import androidx.camera.view.PreviewView
import androidx.compose.animation.core.animateFloat
@@ -77,6 +78,7 @@ import kotlinx.coroutines.delay
import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.MainViewModel
import org.mydomain.myscan.MainViewModel.CaptureState
import org.mydomain.myscan.Navigation
import org.mydomain.myscan.R
import org.mydomain.myscan.Screen
import org.mydomain.myscan.ui.theme.MyScanTheme
@@ -96,6 +98,7 @@ const val ANIMATION_DURATION = 200
@Composable
fun CameraScreen(
viewModel: MainViewModel,
navigation: Navigation,
liveAnalysisState: LiveAnalysisState,
onImageAnalyzed: (ImageProxy) -> Unit,
onFinalizePressed: () -> Unit,
@@ -105,6 +108,8 @@ fun CameraScreen(
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
var isDebugMode by remember { mutableStateOf(false) }
BackHandler { navigation.back() }
val captureController = remember { CameraCaptureController() }
DisposableEffect(Unit) {
onDispose { captureController.shutdown() }
@@ -169,7 +174,7 @@ fun CameraScreen(
onFinalizePressed = onFinalizePressed,
onDebugModeSwitched = { isDebugMode = !isDebugMode },
thumbnailCoords = thumbnailCoords,
toAboutScreen = { viewModel.navigateTo(Screen.About) }
navigation = navigation
)
}
@@ -182,7 +187,7 @@ private fun CameraScreenScaffold(
onFinalizePressed: () -> Unit,
onDebugModeSwitched: () -> Unit,
thumbnailCoords: MutableState<Offset>,
toAboutScreen: () -> Unit,
navigation: Navigation,
) {
var tapCount by remember { mutableStateOf(0) }
var lastTapTime by remember { mutableStateOf(0L) }
@@ -203,8 +208,9 @@ private fun CameraScreenScaffold(
Box {
MyScaffold(
toAboutScreen = toAboutScreen,
toAboutScreen = navigation.toAboutScreen,
pageListState = pageListState,
onBack = navigation.back,
bottomBar = { Bar(cameraUiState.pageCount, onPageCountClick, onFinalizePressed) }
) {
modifier -> CameraPreviewBox(cameraPreview, cameraUiState, onCapture, modifier)
@@ -436,7 +442,6 @@ fun CameraScreenPreviewInLandscapeMode() {
@Composable
private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0f) {
val context = LocalContext.current
MyScanTheme {
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
CameraScreenScaffold(
@@ -456,13 +461,9 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
},
pageListState =
CommonPageListState(
document = DocumentUiModel(
pageIds = listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
imageLoader = { id ->
context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input)
}
}),
document = fakeDocument(
listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
LocalContext.current),
onPageClick = {},
listState = LazyListState(),
),
@@ -472,7 +473,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
onFinalizePressed = {},
onDebugModeSwitched = {},
thumbnailCoords = thumbnailCoords,
toAboutScreen = {}
navigation = dummyNavigation()
)
}
}

View File

@@ -14,7 +14,6 @@
*/
package org.mydomain.myscan.view
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -31,8 +30,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -114,7 +113,7 @@ fun DocumentScreen(
) { modifier ->
DocumentPreview(document, currentPageIndex, onDeleteImage, modifier)
if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog)
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document))
}
if (showPdfDialog.value) {
PdfGenerationBottomSheetWrapper(
@@ -203,7 +202,7 @@ private fun BottomBar(
)
Spacer(modifier = Modifier.width(8.dp))
SecondaryActionButton(
icon = Icons.Default.RestartAlt,
icon = Icons.Default.Close,
contentDescription = stringResource(R.string.restart),
onClick = { showNewDocDialog.value = true },
modifier = Modifier.padding(vertical = 8.dp)
@@ -212,9 +211,9 @@ private fun BottomBar(
}
@Composable
fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>) {
fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>, title: String) {
AlertDialog(
title = { Text(stringResource(R.string.new_document)) },
title = { Text(title) },
text = { Text(stringResource(R.string.new_document_warning)) },
confirmButton = {
TextButton (onClick = {
@@ -236,20 +235,13 @@ fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>)
@Composable
@Preview
fun DocumentScreenPreview() {
val context = LocalContext.current
MyScanTheme {
DocumentScreen(
DocumentUiModel(
fakeDocument(
listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
{ id ->
context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input)
}
}
),
LocalContext.current),
initialPage = 1,
navigation = Navigation(
{}, {}, {}, {}, {}),
navigation = dummyNavigation(),
pdfActions = PdfGenerationActions(
{}, {}, {},
MutableStateFlow(PdfGenerationUiState()),

View File

@@ -0,0 +1,210 @@
/*
* 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 android.Manifest
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
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.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.mydomain.myscan.Navigation
import org.mydomain.myscan.R
import org.mydomain.myscan.rememberCameraPermissionLauncher
import org.mydomain.myscan.ui.theme.MyScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
// FIXME Extract strings
fun HomeScreen(
hasCameraPermission: Boolean,
currentDocument: DocumentUiModel,
navigation: Navigation,
onStartNewScan: () -> Unit
) {
val showCloseDocDialog = rememberSaveable { mutableStateOf(false) }
Scaffold (
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
AboutScreenNavButton(onClick = navigation.toAboutScreen)
}
)
},
bottomBar = {
BottomAppBar {
Spacer(Modifier.weight(1f))
MainActionButton(
onClick = {
if (currentDocument.isEmpty()) {
onStartNewScan()
} else {
showCloseDocDialog.value = true
}
},
icon = Icons.Default.PhotoCamera,
text = "Start a new scan",
modifier = Modifier
.padding(12.dp)
.height(48.dp),
)
}
}
) { padding ->
Column (
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
if (!hasCameraPermission) {
CameraPermissionRationale()
}
if (!currentDocument.isEmpty()) {
SectionTitle("Current document")
CurrentDocumentCard(currentDocument, navigation)
}
if (showCloseDocDialog.value) {
NewDocumentDialog(
onConfirm = onStartNewScan,
showCloseDocDialog,
stringResource(R.string.new_document))
}
}
}
}
@Composable
private fun CameraPermissionRationale() {
val requestPermissionLauncher = rememberCameraPermissionLauncher(onGranted = {}, onDenied = {})
Card(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(
"The app requires camera access to scan documents. " +
"Captured images are stored only on this device and will be deleted " +
"when you close the current document.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(8.dp))
Button(onClick = {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}) {
Text("Grant permission")
}
}
}
}
@Composable
private fun CurrentDocumentCard(
currentDocument: DocumentUiModel,
navigation: Navigation,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp)
) {
currentDocument.load(0)?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.height(100.dp)
.padding(4.dp)
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(pageCountText(currentDocument.pageCount()))
}
MainActionButton(navigation.toDocumentScreen, "Open")
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 12.dp, top = 16.dp, bottom = 8.dp)
)
}
@Preview
@Composable
fun HomeScreenPreviewOnFirstLaunch() {
MyScanTheme {
HomeScreen(
hasCameraPermission = false,
currentDocument = DocumentUiModel(listOf()) { _ -> null },
navigation = dummyNavigation(),
onStartNewScan = {}
)
}
}
@Preview
@Composable
fun HomeScreenPreviewWithCurrentDocument() {
MyScanTheme {
HomeScreen(
hasCameraPermission = true,
currentDocument = fakeDocument(
listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
LocalContext.current),
navigation = dummyNavigation(),
onStartNewScan = {}
)
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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 android.content.Context
import android.graphics.BitmapFactory
import org.mydomain.myscan.Navigation
fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {})
}
fun fakeDocument(pageIds: List<String>, context: Context): DocumentUiModel {
return DocumentUiModel(pageIds) { id ->
context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input)
}
}
}

View File

@@ -7,6 +7,7 @@
<string name="camera_permission_denied">L\'autorisation d\'accès à la caméra a été refusée</string>
<string name="cancel">Annuler</string>
<string name="close">Fermer</string>
<string name="close_document">Fermer le document</string>
<string name="delete_page">Supprimer la page</string>
<string name="document">Document</string>
<string name="error">Erreur : %1$s</string>

View File

@@ -7,6 +7,7 @@
<string name="camera_permission_denied">Camera permission was denied</string>
<string name="cancel">Cancel</string>
<string name="close">Close</string>
<string name="close_document">Close document</string>
<string name="delete_page">Delete page</string>
<string name="document">Document</string>
<string name="error">Error: %1$s</string>