diff --git a/app/src/main/java/org/mydomain/myscan/CameraScreenState.kt b/app/src/main/java/org/mydomain/myscan/CameraScreenState.kt index b58bbfa..f01dde9 100644 --- a/app/src/main/java/org/mydomain/myscan/CameraScreenState.kt +++ b/app/src/main/java/org/mydomain/myscan/CameraScreenState.kt @@ -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, diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index 5ec3fd0..d7a6548 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -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, + context: Context + ): () -> Unit = { + val document = createPdfFromBitmaps(pages) try { val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) if (!downloadsDir.exists()) downloadsDir.mkdirs() diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index 58efa1a..b477df0 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -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.Camera) val currentScreen: StateFlow = _currentScreen.asStateFlow() + // TODO store images on disk + private val _pages = MutableStateFlow>(listOf()) + val pages: StateFlow> = _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 } diff --git a/app/src/main/java/org/mydomain/myscan/Navigation.kt b/app/src/main/java/org/mydomain/myscan/Navigation.kt index c890735..4eb6cb9 100644 --- a/app/src/main/java/org/mydomain/myscan/Navigation.kt +++ b/app/src/main/java/org/mydomain/myscan/Navigation.kt @@ -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() } diff --git a/app/src/main/java/org/mydomain/myscan/view/Camera.kt b/app/src/main/java/org/mydomain/myscan/view/Camera.kt index dc839ad..3fb0073 100644 --- a/app/src/main/java/org/mydomain/myscan/view/Camera.kt +++ b/app/src/main/java/org/mydomain/myscan/view/Camera.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/org/mydomain/myscan/view/FinalizeDocument.kt b/app/src/main/java/org/mydomain/myscan/view/FinalizeDocument.kt new file mode 100644 index 0000000..39e838d --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/view/FinalizeDocument.kt @@ -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 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) + ) + } + } + } + } +} diff --git a/app/src/main/java/org/mydomain/myscan/view/PagePreview.kt b/app/src/main/java/org/mydomain/myscan/view/PagePreview.kt deleted file mode 100644 index b49377b..0000000 --- a/app/src/main/java/org/mydomain/myscan/view/PagePreview.kt +++ /dev/null @@ -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" - ) - } - } -}