Multi-page step 2: FinalizeDocumentScreen

This commit is contained in:
Pierre-Yves Nicolas
2025-06-02 21:27:50 +02:00
parent 0b76c3fc1e
commit 95ae4fcea3
7 changed files with 182 additions and 115 deletions

View File

@@ -4,6 +4,7 @@ import android.graphics.Bitmap
import androidx.compose.runtime.Immutable
@Immutable
// TODO Rename to LiveAnalysisState
data class CameraScreenState(
val detectionMessage: String? = null,
val inferenceTime: Long = 0L,

View File

@@ -22,7 +22,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.mydomain.myscan.ui.theme.MyScanTheme
import org.mydomain.myscan.view.CameraScreen
import org.mydomain.myscan.view.PagePreviewScreen
import org.mydomain.myscan.view.FinalizeDocumentScreen
import org.opencv.android.OpenCVLoader
import java.io.File
import java.io.FileOutputStream
@@ -38,22 +38,24 @@ class MainActivity : ComponentActivity() {
setContent {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val cameraScreenState by viewModel.cameraScreenState.collectAsStateWithLifecycle()
val pages by viewModel.pages.collectAsStateWithLifecycle()
val context = LocalContext.current
MyScanTheme {
Scaffold { innerPadding ->
Column (modifier = Modifier.padding(innerPadding)) {
when (val screen = currentScreen) {
when (currentScreen) {
is Screen.Camera -> {
CameraScreen(viewModel, cameraScreenState,
onImageAnalyzed = { image -> viewModel.segment(image) } )
onImageAnalyzed = { image -> viewModel.segment(image) },
onFinalizePressed = { viewModel.navigateTo(Screen.FinalizeDocument) }
)
}
is Screen.PagePreview -> {
PagePreviewScreen (
image = screen.image,
isProcessing = screen.isProcessing,
is Screen.FinalizeDocument -> {
FinalizeDocumentScreen (
viewModel,
onBackPressed = { viewModel.navigateTo(Screen.Camera) },
onSavePressed = createPdfAndSave(context),
onSharePressed = createPdfAndShare(context),
onSavePressed = savePdf(pages, context),
// TODO "on share"
)
}
}
@@ -93,8 +95,11 @@ class MainActivity : ComponentActivity() {
}
}
fun createPdfAndSave(context: Context): (Bitmap) -> Unit = { bitmap ->
val document = createPdfFromBitmaps(listOf(bitmap))
private fun savePdf(
pages: List<Bitmap>,
context: Context
): () -> Unit = {
val document = createPdfFromBitmaps(pages)
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.camera.core.ImageProxy
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -33,6 +35,10 @@ class MainViewModel(private val imageSegmentationService: ImageSegmentationServi
private val _currentScreen = MutableStateFlow<Screen>(Screen.Camera)
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
// TODO store images on disk
private val _pages = MutableStateFlow<List<Bitmap>>(listOf())
val pages: StateFlow<List<Bitmap>> = _pages
init {
viewModelScope.launch {
imageSegmentationService.initialize()
@@ -96,6 +102,8 @@ class MainViewModel(private val imageSegmentationService: ImageSegmentationServi
}
fun addPage(bitmap: Bitmap) {
// TODO
_pages.update { list -> list.plus(bitmap) }
}
fun pageCount(): Int = _pages.value.size
}

View File

@@ -1,11 +1,6 @@
package org.mydomain.myscan
import android.graphics.Bitmap
sealed class Screen {
object Camera : Screen()
data class PagePreview(
val image: Bitmap? = null,
val isProcessing: Boolean = true
) : Screen()
object FinalizeDocument : Screen()
}

View File

@@ -19,14 +19,17 @@ import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
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.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.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -58,11 +61,14 @@ import org.mydomain.myscan.scaledTo
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
// TODO Split this big file
@Composable
fun CameraScreen(
viewModel: MainViewModel,
uiState: CameraScreenState,
onImageAnalyzed: (ImageProxy) -> Unit,
onFinalizePressed: () -> Unit
) {
// TODO Should we move those variables to ViewModel?
// TODO pause the live analysis when displaying the PageValidationDialogs
@@ -91,19 +97,8 @@ fun CameraScreen(
}
}
Column {
val width = LocalConfiguration.current.screenWidthDp
val height = width / 3 * 4
Box(
modifier = Modifier
.width(width.dp)
.height(height.dp)
) {
CameraPreview(
onImageAnalyzed = onImageAnalyzed,
captureController = captureController)
AnalysisOverlay(uiState)
}
Box(modifier = Modifier.fillMaxSize()) {
CameraPreviewWithOverlay(onImageAnalyzed, captureController, uiState)
MessageBox(uiState.inferenceTime)
Button(
onClick = {
@@ -121,10 +116,14 @@ fun CameraScreen(
}
}
)},
modifier = Modifier.align(Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 96.dp),
) {
Text("Capture")
}
CameraScreenFooter(
pageCount = viewModel.pageCount(),
onFinalizePressed = onFinalizePressed,
modifier = Modifier.align(Alignment.BottomCenter))
}
if (showPageDialog.value) {
@@ -145,6 +144,27 @@ fun CameraScreen(
}
}
@Composable
private fun CameraPreviewWithOverlay(
onImageAnalyzed: (ImageProxy) -> Unit,
captureController: CameraCaptureController,
uiState: CameraScreenState
) {
val width = LocalConfiguration.current.screenWidthDp
val height = width / 3 * 4
Box(
modifier = Modifier
.width(width.dp)
.height(height.dp)
) {
CameraPreview(
onImageAnalyzed = onImageAnalyzed,
captureController = captureController
)
AnalysisOverlay(uiState)
}
}
@Composable
fun CameraPreview(
modifier: Modifier = Modifier,
@@ -299,3 +319,36 @@ fun MessageBox(inferenceTime: Long) {
color = Color.Gray,
)
}
@Composable
fun CameraScreenFooter(
pageCount: Int,
onFinalizePressed: () -> Unit,
modifier: Modifier,
) {
Surface (
color = MaterialTheme.colorScheme.inverseOnSurface,
tonalElevation = 4.dp,
modifier = modifier.fillMaxWidth().height(56.dp)
) {
Row (
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Pages : $pageCount",
style = MaterialTheme.typography.bodyMedium
)
Button (
onClick = onFinalizePressed,
enabled = pageCount > 0
) {
Text("Finish")
}
}
}
}

View File

@@ -0,0 +1,87 @@
package org.mydomain.myscan.view
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.mydomain.myscan.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FinalizeDocumentScreen(
viewModel: MainViewModel = viewModel(),
onBackPressed: () -> Unit,
onSavePressed: () -> Unit
) {
val pages: List<Bitmap> by viewModel.pages.collectAsStateWithLifecycle()
Scaffold (
topBar = {
TopAppBar(
title = { Text("Finalize document") },
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
Button(onClick = onSavePressed) {
Text(text = "Save PDF")
}
}
)
}
) { padding ->
LazyColumn (
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFFF0F0F0)),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp)
) {
items(pages) { bitmap ->
Card(
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier.fillMaxWidth()
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Page",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(bitmap.width.toFloat() / bitmap.height)
.background(Color.White)
)
}
}
}
}
}

View File

@@ -1,82 +0,0 @@
package org.mydomain.myscan.view
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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
@Composable
fun PagePreviewScreen(
image: Bitmap?,
isProcessing: Boolean,
onBackPressed: () -> Unit,
onSavePressed: (Bitmap) -> Unit,
onSharePressed: (Bitmap) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()) {
when {
isProcessing -> {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
)
}
image != null -> {
Image(
bitmap = image.asImageBitmap(),
contentDescription = "Document preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Button (onClick = { onSavePressed(image) }) {
Text("Save PDF")
}
Spacer(modifier = Modifier.width(8.dp))
Button (onClick = { onSharePressed(image) }) {
Text("Share PDF")
}
}
}
else -> {
Text(
text = "No image is available.",
modifier = Modifier.align(Alignment.Center)
)
}
}
IconButton (
onClick = onBackPressed,
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
}