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

View File

@@ -68,9 +68,9 @@ class MainViewModel(
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow() val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
private var lastSuccessfulLiveAnalysisState: LiveAnalysisState? = null 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() } 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()) private val _pageIds = MutableStateFlow(imageRepository.imageIds())
val documentUiModel: StateFlow<DocumentUiModel> = val documentUiModel: StateFlow<DocumentUiModel> =

View File

@@ -15,6 +15,7 @@
package org.mydomain.myscan package org.mydomain.myscan
sealed class Screen { sealed class Screen {
object Home : Screen()
object Camera : Screen() object Camera : Screen()
data class Document(val initialPage: Int = 0) : Screen() data class Document(val initialPage: Int = 0) : Screen()
object About : Screen() object About : Screen()
@@ -22,6 +23,7 @@ sealed class Screen {
} }
data class Navigation( data class Navigation(
val toHomeScreen: () -> Unit,
val toCameraScreen: () -> Unit, val toCameraScreen: () -> Unit,
val toDocumentScreen: () -> Unit, val toDocumentScreen: () -> Unit,
val toAboutScreen: () -> 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.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Button
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -38,13 +38,15 @@ import org.mydomain.myscan.R
fun MainActionButton( fun MainActionButton(
onClick: () -> Unit, onClick: () -> Unit,
text: String, text: String,
icon: ImageVector, icon: ImageVector? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
iconDescription: String? = null, iconDescription: String? = null,
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
Button(onClick = onClick, enabled = enabled, modifier = modifier) { Button(onClick = onClick, enabled = enabled, modifier = modifier) {
icon?.let {
Icon(icon, contentDescription = iconDescription) Icon(icon, contentDescription = iconDescription)
}
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(text) Text(text)
} }
@@ -93,7 +95,7 @@ fun AboutScreenNavButton(
modifier = modifier modifier = modifier
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Info, imageVector = Icons.Default.Info,
contentDescription = stringResource(R.string.about), contentDescription = stringResource(R.string.about),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)) tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
} }

View File

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

View File

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

View File

@@ -14,7 +14,6 @@
*/ */
package org.mydomain.myscan.view package org.mydomain.myscan.view
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
@@ -31,8 +30,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
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.Add 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.PictureAsPdf
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -114,7 +113,7 @@ fun DocumentScreen(
) { modifier -> ) { modifier ->
DocumentPreview(document, currentPageIndex, onDeleteImage, modifier) DocumentPreview(document, currentPageIndex, onDeleteImage, modifier)
if (showNewDocDialog.value) { if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog) NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document))
} }
if (showPdfDialog.value) { if (showPdfDialog.value) {
PdfGenerationBottomSheetWrapper( PdfGenerationBottomSheetWrapper(
@@ -203,7 +202,7 @@ private fun BottomBar(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
SecondaryActionButton( SecondaryActionButton(
icon = Icons.Default.RestartAlt, icon = Icons.Default.Close,
contentDescription = stringResource(R.string.restart), contentDescription = stringResource(R.string.restart),
onClick = { showNewDocDialog.value = true }, onClick = { showNewDocDialog.value = true },
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
@@ -212,9 +211,9 @@ private fun BottomBar(
} }
@Composable @Composable
fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>) { fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>, title: String) {
AlertDialog( AlertDialog(
title = { Text(stringResource(R.string.new_document)) }, title = { Text(title) },
text = { Text(stringResource(R.string.new_document_warning)) }, text = { Text(stringResource(R.string.new_document_warning)) },
confirmButton = { confirmButton = {
TextButton (onClick = { TextButton (onClick = {
@@ -236,20 +235,13 @@ fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>)
@Composable @Composable
@Preview @Preview
fun DocumentScreenPreview() { fun DocumentScreenPreview() {
val context = LocalContext.current
MyScanTheme { MyScanTheme {
DocumentScreen( DocumentScreen(
DocumentUiModel( fakeDocument(
listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }, listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
{ id -> LocalContext.current),
context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input)
}
}
),
initialPage = 1, initialPage = 1,
navigation = Navigation( navigation = dummyNavigation(),
{}, {}, {}, {}, {}),
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(
{}, {}, {}, {}, {}, {},
MutableStateFlow(PdfGenerationUiState()), 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="camera_permission_denied">L\'autorisation d\'accès à la caméra a été refusée</string>
<string name="cancel">Annuler</string> <string name="cancel">Annuler</string>
<string name="close">Fermer</string> <string name="close">Fermer</string>
<string name="close_document">Fermer le document</string>
<string name="delete_page">Supprimer la page</string> <string name="delete_page">Supprimer la page</string>
<string name="document">Document</string> <string name="document">Document</string>
<string name="error">Erreur : %1$s</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="camera_permission_denied">Camera permission was denied</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="close_document">Close document</string>
<string name="delete_page">Delete page</string> <string name="delete_page">Delete page</string>
<string name="document">Document</string> <string name="document">Document</string>
<string name="error">Error: %1$s</string> <string name="error">Error: %1$s</string>