Home screen
This commit is contained in:
committed by
pynicolas
parent
e74bbcd0d6
commit
5c7d603c3e
@@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
53
app/src/main/java/org/mydomain/myscan/PermissionHelpers.kt
Normal file
53
app/src/main/java/org/mydomain/myscan/PermissionHelpers.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(icon, contentDescription = iconDescription)
|
icon?.let {
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
210
app/src/main/java/org/mydomain/myscan/view/HomeScreen.kt
Normal file
210
app/src/main/java/org/mydomain/myscan/view/HomeScreen.kt
Normal 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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/java/org/mydomain/myscan/view/PreviewUtils.kt
Normal file
31
app/src/main/java/org/mydomain/myscan/view/PreviewUtils.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user