New UX on capture: freeze preview, no more dialog
This commit is contained in:
@@ -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)
|
||||
)
|
||||
|
||||
@@ -56,10 +56,8 @@ class MainViewModel(
|
||||
private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds())
|
||||
val pageIds: StateFlow<List<String>> = _pageIds
|
||||
|
||||
private var _pageToValidate = MutableStateFlow<Bitmap?>(null)
|
||||
val pageToValidate: StateFlow<Bitmap?> = _pageToValidate.asStateFlow()
|
||||
|
||||
var liveAnalysisEnabled = true
|
||||
private val _captureState = MutableStateFlow<CaptureState>(CaptureState())
|
||||
val captureState: StateFlow<CaptureState> = _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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<PreviewView?>(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 <Capture>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user