From e894c14260971db0f21f21a85e118493230c8258 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Mon, 23 Jun 2025 20:59:18 +0200 Subject: [PATCH] New UX on capture: freeze preview, no more dialog --- .../java/org/mydomain/myscan/MainActivity.kt | 2 +- .../java/org/mydomain/myscan/MainViewModel.kt | 51 ++++++-- .../org/mydomain/myscan/view/CameraPreview.kt | 2 + .../org/mydomain/myscan/view/CameraScreen.kt | 122 +++++++++++------- .../mydomain/myscan/view/PageValidation.kt | 98 -------------- 5 files changed, 118 insertions(+), 157 deletions(-) delete mode 100644 app/src/main/java/org/mydomain/myscan/view/PageValidation.kt diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index dd71619..e65e02e 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -61,7 +61,7 @@ class MainActivity : ComponentActivity() { Scaffold { innerPadding-> CameraScreen( viewModel, liveAnalysisState, - onImageAnalyzed = { image -> viewModel.segment(image) }, + onImageAnalyzed = { image -> viewModel.liveAnalysis(image) }, onFinalizePressed = { viewModel.navigateTo(Screen.FinalizeDocument) }, modifier = Modifier.padding(innerPadding) ) diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index b3c3416..8ea3db1 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -56,10 +56,8 @@ class MainViewModel( private val _pageIds = MutableStateFlow>(imageRepository.imageIds()) val pageIds: StateFlow> = _pageIds - private var _pageToValidate = MutableStateFlow(null) - val pageToValidate: StateFlow = _pageToValidate.asStateFlow() - - var liveAnalysisEnabled = true + private val _captureState = MutableStateFlow(CaptureState()) + val captureState: StateFlow = _captureState init { viewModelScope.launch { @@ -80,8 +78,28 @@ class MainViewModel( } } - fun segment(imageProxy: ImageProxy) { - if (!liveAnalysisEnabled) { + data class CaptureState(val frozenImage: Bitmap? = null, val processedImage: Bitmap? = null) { + fun isIdle(): Boolean { return frozenImage == null } + fun isProcessed(): Boolean { return processedImage != null } + fun withProcessed(processedImage: Bitmap? = null): CaptureState { + return if (processedImage == null) { + CaptureState() + } else { + CaptureState(frozenImage, processedImage) + } + } + } + + fun onCapturePressed(frozenImage: Bitmap?) { + _captureState.value = CaptureState(frozenImage) + } + + fun onCaptureProcessed(captured: Bitmap?) { + _captureState.value = _captureState.value.withProcessed(captured) + } + + fun liveAnalysis(imageProxy: ImageProxy) { + if (!_captureState.value.isIdle()) { imageProxy.close() return } @@ -99,11 +117,15 @@ class MainViewModel( _currentScreen.value = screen } - fun processCapturedImageThen(imageProxy: ImageProxy, onResult: (Bitmap?) -> Unit) { - viewModelScope.launch { - _pageToValidate.value = processCapturedImage(imageProxy) - imageProxy.close() - onResult(_pageToValidate.value) + fun onImageCaptured(imageProxy: ImageProxy?) { + if (imageProxy != null) { + viewModelScope.launch { + val image = processCapturedImage(imageProxy) + imageProxy.close() + onCaptureProcessed(image) + } + } else { + onCaptureProcessed(null) } } @@ -122,7 +144,12 @@ class MainViewModel( return@withContext corrected } - fun addPage(bitmap: Bitmap, quality: Int = 75) { + fun addProcessedImage(quality: Int = 75) { + val bitmap = _captureState.value.processedImage + _captureState.value = CaptureState() + if (bitmap == null) { + return + } val resized = resizeImage(bitmap) val outputStream = ByteArrayOutputStream() resized.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) diff --git a/app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt b/app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt index 20a4b65..463e8c4 100644 --- a/app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt +++ b/app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt @@ -65,6 +65,7 @@ fun CameraPreview( modifier: Modifier = Modifier, onImageAnalyzed: (ImageProxy) -> Unit, captureController: CameraCaptureController, + onPreviewViewReady: (PreviewView) -> Unit, ) { val context = LocalContext.current val requestPermissionLauncher = rememberLauncherForActivityResult( @@ -97,6 +98,7 @@ fun CameraPreview( val previewView = PreviewView(it).apply { layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) scaleType = PreviewView.ScaleType.FIT_CENTER + onPreviewViewReady(this) } val executor = Executors.newSingleThreadExecutor() cameraProviderFuture.addListener({ diff --git a/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt b/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt index 0b38d6e..ef73f57 100644 --- a/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt +++ b/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt @@ -14,8 +14,12 @@ */ package org.mydomain.myscan.view +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.util.Log import androidx.camera.core.ImageProxy +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Image import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -37,19 +41,24 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay import org.mydomain.myscan.LiveAnalysisState import org.mydomain.myscan.MainViewModel +import org.mydomain.myscan.MainViewModel.CaptureState import org.mydomain.myscan.ui.theme.MyScanTheme @Composable @@ -60,64 +69,41 @@ fun CameraScreen( onFinalizePressed: () -> Unit, modifier: Modifier, ) { - val showPageDialog = rememberSaveable { mutableStateOf(false) } - val isProcessing = rememberSaveable { mutableStateOf(false) } - val pageToValidate by viewModel.pageToValidate.collectAsStateWithLifecycle() + var previewView by remember { mutableStateOf(null) } val captureController = remember { CameraCaptureController() } DisposableEffect(Unit) { onDispose { captureController.shutdown() } } + val captureState by viewModel.captureState.collectAsStateWithLifecycle() + if (captureState.isProcessed()) { + LaunchedEffect(captureState) { + delay(1500) + viewModel.addProcessedImage() + } + } + CameraScreenContent( modifier, cameraPreview = { CameraPreview( onImageAnalyzed = onImageAnalyzed, - captureController = captureController + captureController = captureController, + onPreviewViewReady = { view -> previewView = view } ) }, pageCount = viewModel.pageCount(), - liveAnalysisState = if (showPageDialog.value) LiveAnalysisState() else liveAnalysisState, + liveAnalysisState = liveAnalysisState, onCapture = { Log.i("MyScan", "Pressed ") - viewModel.liveAnalysisEnabled = false - showPageDialog.value = true - isProcessing.value = true + viewModel.onCapturePressed(previewView?.bitmap) captureController.takePicture( - onImageCaptured = { imageProxy -> - if (imageProxy != null) { - viewModel.processCapturedImageThen(imageProxy) { - isProcessing.value = false - viewModel.liveAnalysisEnabled = true - Log.i("MyScan", "Capture process finished") - } - } else { - Log.e("MyScan", "Error during image capture") - isProcessing.value = false - viewModel.liveAnalysisEnabled = true - } - } + onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) } ) }, - onFinalizePressed = onFinalizePressed + onFinalizePressed = onFinalizePressed, + captureState = captureState ) - - if (showPageDialog.value) { - PageValidationDialog( - isProcessing = isProcessing.value, - pageBitmap = pageToValidate, - onConfirm = { - pageToValidate?.let { viewModel.addPage(it) } - showPageDialog.value = false - }, - onReject = { - showPageDialog.value = false - }, - onDismiss = { - showPageDialog.value = false - } - ) - } } @Composable @@ -127,10 +113,11 @@ private fun CameraScreenContent( pageCount: Int, liveAnalysisState: LiveAnalysisState, onCapture: () -> Unit, - onFinalizePressed: () -> Unit + onFinalizePressed: () -> Unit, + captureState: CaptureState ) { Box(modifier = modifier.fillMaxSize()) { - CameraPreviewWithOverlay(cameraPreview, liveAnalysisState) + CameraPreviewWithOverlay(cameraPreview, liveAnalysisState, captureState) MessageBox(liveAnalysisState.inferenceTime) CaptureButton( @@ -144,6 +131,17 @@ private fun CameraScreenContent( onFinalizePressed = onFinalizePressed, modifier = Modifier.align(Alignment.BottomCenter) ) + captureState.processedImage?.let { + Surface ( + color = Color.Black.copy(alpha = 0.3f), + modifier = Modifier.fillMaxSize()) + {} + Image( + bitmap = it.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize().padding(24.dp) + ) + } } } @@ -180,7 +178,8 @@ fun CaptureButton(onClick: () -> Unit, modifier: Modifier) { @Composable private fun CameraPreviewWithOverlay( cameraPreview: @Composable () -> Unit, - liveAnalysisState: LiveAnalysisState + liveAnalysisState: LiveAnalysisState, + captureState: CaptureState ) { val width = LocalConfiguration.current.screenWidthDp val height = width / 3 * 4 @@ -191,6 +190,13 @@ private fun CameraPreviewWithOverlay( ) { cameraPreview() AnalysisOverlay(liveAnalysisState) + captureState.frozenImage?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = null, + ) + + } } } @@ -241,6 +247,17 @@ fun CameraScreenFooter( @Preview(showBackground = true) @Composable fun CameraScreenPreview() { + ScreenPreview(CaptureState()) +} + +@Preview(showBackground = true) +@Composable +fun CameraScreenPreviewWithProcessedImage() { + ScreenPreview(CaptureState(processedImage = debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"))) +} + +@Composable +private fun ScreenPreview(captureState: CaptureState) { MyScanTheme { CameraScreenContent( modifier = Modifier, @@ -249,14 +266,27 @@ fun CameraScreenPreview() { modifier = Modifier .fillMaxSize() .background(Color.DarkGray), - contentAlignment = Alignment.Center + contentAlignment = Alignment.TopCenter ) { - Text("Camera Preview", color = Color.White) + Image( + debugImage("uncropped/img01.jpg").asImageBitmap(), + contentDescription = null + ) } }, pageCount = 3, liveAnalysisState = LiveAnalysisState(), onCapture = {}, - onFinalizePressed = {}) + onFinalizePressed = {}, + captureState = captureState + ) + } +} + +@Composable +private fun debugImage(imgName: String): Bitmap { + val context = LocalContext.current + return context.assets.open(imgName).use { input -> + BitmapFactory.decodeStream(input) } } diff --git a/app/src/main/java/org/mydomain/myscan/view/PageValidation.kt b/app/src/main/java/org/mydomain/myscan/view/PageValidation.kt deleted file mode 100644 index 1f35135..0000000 --- a/app/src/main/java/org/mydomain/myscan/view/PageValidation.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2025 Pierre-Yves Nicolas - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, either version 3 of the License, or (at your option) - * any later version. - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -package org.mydomain.myscan.view - -import android.graphics.Bitmap -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog - -@Composable -fun PageValidationDialog( - isProcessing: Boolean, - pageBitmap: Bitmap?, - onConfirm: () -> Unit, - onReject: () -> Unit, - onDismiss: () -> Unit -) { - Dialog (onDismissRequest = onDismiss) { - Surface ( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - modifier = Modifier - .fillMaxSize() - .aspectRatio(3f / 4f) - ) { - if (isProcessing) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else { - Column ( - Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - if (pageBitmap == null) { - Text("Failed to process image") - } else { - Image( - bitmap = pageBitmap.asImageBitmap(), - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentScale = ContentScale.Fit - ) - } - - Row ( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedButton (onClick = onReject) { - Text("Reject") - } - Button (onClick = onConfirm) { - Text("OK") - } - } - } - } - } - } -}