From d03d4117068dd631619fc726fc32a45669e7d75e Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Tue, 5 May 2026 13:42:10 +0200 Subject: [PATCH] Plug input data to EditPageScreen --- .../java/org/fairscan/app/MainActivity.kt | 9 +- .../java/org/fairscan/app/MainViewModel.kt | 53 ++++++++- .../java/org/fairscan/app/ui/Navigation.kt | 6 +- .../app/ui/screens/document/DocumentScreen.kt | 45 +++++-- .../ui/screens/document/DocumentUiState.kt | 1 + .../app/ui/screens/edit/EditPageScreen.kt | 111 +++--------------- .../ui/screens/edit/EditPageScreenState.kt | 19 +-- app/src/main/res/values/strings.xml | 2 - 8 files changed, 126 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 1caeefe..0b56f1a 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -124,6 +124,7 @@ class MainActivity : ComponentActivity() { val importState by cameraViewModel.importState.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle() + val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle() val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle() val cameraPermission = rememberCameraPermissionState() CollectCameraEvents(cameraViewModel, viewModel) @@ -179,10 +180,10 @@ class MainActivity : ComponentActivity() { ) } is Screen.Main.EditImage -> { - val pageIndex = (currentScreen as Screen.Main.EditImage).pageIndex EditPageScreen( - pageId = documentUiState.document.pages[pageIndex].key.pageId, - imageRepository = imageRepository, + pageId = documentUiState.currentPage?.key?.pageId ?: "", + onLoad = { id -> viewModel.loadCropInitialState(id)}, + initState = cropInitialState, navigation = navigation, onUpdatePageQuad = { id, quad, onComplete -> }, ) @@ -467,7 +468,7 @@ class MainActivity : ComponentActivity() { private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation( toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) }, - toEditImageScreen = { pageIndex -> viewModel.navigateTo(Screen.Main.EditImage(pageIndex)) }, + toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) }, toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) }, toExportScreen = { viewModel.navigateTo(Screen.Main.Export) }, toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) }, diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index d0f46cd..b3b6160 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -14,12 +14,16 @@ */ package org.fairscan.app +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -34,14 +38,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fairscan.app.data.ImageRepository import org.fairscan.app.domain.CapturedPage +import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.ScanPage import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.Screen import org.fairscan.app.ui.screens.document.CurrentPageUiState import org.fairscan.app.ui.screens.document.DocumentUiState +import org.fairscan.app.ui.screens.edit.CropInitState import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.PageThumbnail import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ImageSize import kotlin.math.min @OptIn(ExperimentalCoroutinesApi::class) @@ -95,7 +102,8 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() { page?.let { val isLoading = (it.id == loadingId) val bitmap = imageRepository.jpegBytes(it.key())?.toBitmap() - CurrentPageUiState(it.key(), bitmap, it.colorMode, isLoading) + val canBeCropped = page.metadata != null + CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading) } } .flowOn(Dispatchers.IO) @@ -212,4 +220,47 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() { _pages.value = pages } } + + private val _cropInitState = MutableStateFlow(CropInitState.Loading) + val cropInitState: StateFlow = _cropInitState + + private var cropInitialStateJob: Job? = null + fun loadCropInitialState(pageId: String) { + cropInitialStateJob?.cancel() + cropInitialStateJob = viewModelScope.launch { + _cropInitState.value = CropInitState.Loading + + val page = _pages.value.find { it.id == pageId } + ?: return@launch + + val metadata = page.metadata + val baseRotation = metadata?.baseRotation ?: Rotation.R0 + val rotation = baseRotation.add(page.manualRotation) + + val bitmap = withContext(Dispatchers.IO) { + val source = imageRepository.source(page.id) + val bytes = source?.bytes ?: return@withContext null + + val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + if (original != null && rotation != Rotation.R0) { + val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) } + Bitmap.createBitmap( + original, 0, 0, original.width, original.height, matrix, true + ) + } else { + original + } + } + + val quad = metadata?.normalizedQuad?.rotate90( + rotation.degrees / 90, + ImageSize(1, 1) + ) + + _cropInitState.value = if (bitmap == null || quad == null) + CropInitState.Error + else + CropInitState.Ready(page.id, bitmap, quad) + } + } } diff --git a/app/src/main/java/org/fairscan/app/ui/Navigation.kt b/app/src/main/java/org/fairscan/app/ui/Navigation.kt index a398811..79f7732 100644 --- a/app/src/main/java/org/fairscan/app/ui/Navigation.kt +++ b/app/src/main/java/org/fairscan/app/ui/Navigation.kt @@ -17,7 +17,7 @@ package org.fairscan.app.ui sealed class Screen { sealed class Main : Screen() { object Camera : Main() - data class EditImage(val pageIndex: Int) : Main() + object EditImage : Main() data class Document(val initialPage: Int = 0) : Main() object Export : Main() } @@ -30,7 +30,7 @@ sealed class Screen { data class Navigation( val toCameraScreen: () -> Unit, - val toEditImageScreen: (Int) -> Unit, + val toEditImageScreen: () -> Unit, val toDocumentScreen: () -> Unit, val toExportScreen: () -> Unit, val toAboutScreen: () -> Unit, @@ -64,7 +64,7 @@ data class NavigationState private constructor(val stack: List, val root root -> this // Back handled by system is Screen.Main.Camera -> this // Back handled by system is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera)) - is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document(initialPage = (current as Screen.Main.EditImage).pageIndex))) + is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document())) is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera)) is Screen.Overlay -> copy(stack = stack.dropLast(1)) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index a3e3bbc..99f75e3 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Contrast +import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.RotateLeft @@ -132,6 +133,7 @@ fun DocumentScreen( { showDeletePageDialog.value = true }, onRotateImage, onToggleColorMode, + navigation, modifier ) if (showDeletePageDialog.value) { @@ -150,6 +152,7 @@ private fun DocumentPreview( onDeleteImage: () -> Unit, onRotateImage: (Boolean) -> Unit, onToggleColorMode: () -> Unit, + navigation: Navigation, modifier: Modifier, ) { val currentPageIndex = uiState.currentPageIndex @@ -194,15 +197,12 @@ private fun DocumentPreview( CircularProgressIndicator() } } - uiState.currentPage?.colorMode?.let { - ColorModeButton( - currentColorMode = it, - onToggle = { onToggleColorMode() }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(8.dp) - ) - } + EditButtons( + uiState, + onToggleColorMode, + navigation, + modifier = Modifier.align(Alignment.BottomStart) + ) RotationButtons(onRotateImage, Modifier.align(Alignment.BottomCenter)) SecondaryActionButton( Icons.Outlined.Delete, @@ -253,6 +253,31 @@ fun RotationButtons( } } +@Composable +fun EditButtons( + uiState: DocumentUiState, + onToggleColorMode: () -> Unit, + navigation: Navigation, + modifier: Modifier +) { + Row(modifier = modifier.padding(8.dp)) { + uiState.currentPage?.colorMode?.let { + ColorModeButton( + currentColorMode = it, + onToggle = { onToggleColorMode() }, + ) + } + Spacer(Modifier.width(8.dp)) + if (uiState.currentPage?.canBeCropped ?: false) { + SecondaryActionButton( + icon = Icons.Default.Crop, + contentDescription = "Crop", // TODO externalize string + onClick = navigation.toEditImageScreen, + ) + } + } +} + @Composable fun ColorModeButton( currentColorMode: ColorMode, @@ -348,7 +373,7 @@ fun DocumentScreenPreview() { ) val key = PageViewKey("123", Rotation.R0, null) DocumentScreen( - uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR), document), + uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document), navigation = dummyNavigation(), onExportClick = {}, onDeleteImage = { }, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt index 5c7d4fd..dff4c07 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt @@ -29,5 +29,6 @@ data class CurrentPageUiState( val key: PageViewKey, val bitmap: Bitmap?, val colorMode: ColorMode?, + val canBeCropped: Boolean = false, val isLoading: Boolean = false, ) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt index dad591a..3adc809 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt @@ -16,8 +16,8 @@ package org.fairscan.app.ui.screens.edit import android.annotation.SuppressLint import android.content.res.Configuration +import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Matrix import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.awaitEachGesture @@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -50,100 +49,43 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext import org.fairscan.app.R -import org.fairscan.app.data.ImageRepository -import org.fairscan.app.data.ImageTransformations -import org.fairscan.app.domain.Rotation import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.components.AppOverflowMenu import org.fairscan.app.ui.components.BackButton -import org.fairscan.app.ui.components.ConfirmationDialog import org.fairscan.app.ui.components.MainActionButton import org.fairscan.app.ui.components.isLandscape import org.fairscan.app.ui.dummyNavigation import org.fairscan.app.ui.theme.FairScanTheme -import org.fairscan.imageprocessing.ImageSize import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad -import java.io.File @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditPageScreen( pageId: String, - imageRepository: ImageRepository, + onLoad: (String) -> Unit, + initState: CropInitState, navigation: Navigation, onUpdatePageQuad: (String, Quad, onComplete: () -> Unit) -> Unit, - onReportProblem: () -> Unit = {}, ) { - val showDiscardChangesDialog = rememberSaveable { mutableStateOf(false) } val state = remember { EditPageScreenState() } val quadHandler = remember { QuadEditingHandler() } - val handleBack = { - if (state.hasUnsavedChanges()) { - showDiscardChangesDialog.value = true - } else { - navigation.back() - } + if (initState is CropInitState.Ready && initState.pageId == pageId) { + state.bitmap = initState.bitmap + state.setInitialQuad(initState.quad) } - BackHandler { handleBack() } - - val isPreview = LocalInspectionMode.current - if (isPreview) { - val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input -> - BitmapFactory.decodeStream(input) - } - state.bitmap = dummyImage - state.setInitialQuad(Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9))) - } - - val totalRotation = remember { mutableStateOf(Rotation.R0) } + BackHandler { navigation.back() } LaunchedEffect(pageId) { - val metadata = imageRepository.getPageMetadata(pageId) - val baseRotation = metadata?.baseRotation ?: Rotation.R0 - val manualRotation = imageRepository.getManualRotation(pageId) - val rotation = baseRotation.add(manualRotation) - totalRotation.value = rotation - - val bitmap = withContext(Dispatchers.IO) { - val sourceJpegBytes = imageRepository.sourceJpegBytes(pageId) - if (sourceJpegBytes != null) { - val original = BitmapFactory.decodeByteArray(sourceJpegBytes, 0, sourceJpegBytes.size) - if (original != null && rotation != Rotation.R0) { - // Adjust the displayed bitmap's rotation to what is in the metadata - val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) } - val rotated = android.graphics.Bitmap.createBitmap( - original, 0, 0, original.width, original.height, matrix, true - ) - if (rotated !== original) { - original.recycle() - } - rotated - } else { - original - } - } else null - } - state.bitmap = bitmap // assigned on the main thread after withContext returns - if (metadata?.normalizedQuad != null) { - // Rotate the quad to match the rotated bitmap display - val rotatedQuad = metadata.normalizedQuad.rotate90( - rotation.degrees / 90, - ImageSize(1, 1) - ) - state.setInitialQuad(rotatedQuad) - } + onLoad(pageId) } val isLandscape = isLandscape(LocalConfiguration.current) @@ -178,7 +120,7 @@ fun EditPageScreen( } BackButton( - onClick = handleBack, + onClick = navigation.back, modifier = Modifier .align(Alignment.TopStart) .windowInsetsPadding(WindowInsets.safeDrawing) @@ -198,6 +140,7 @@ fun EditPageScreen( .padding(16.dp) .windowInsetsPadding(WindowInsets.safeDrawing), onConfirm = { + /* val quad = state.editableQuad if (quad != null) { // Reverse the total rotation to get back to original source image coordinates @@ -210,21 +153,11 @@ fun EditPageScreen( } else { navigation.back() } + */ } ) } } - - if (showDiscardChangesDialog.value) { - ConfirmationDialog( - title = stringResource(R.string.discard_changes), - message = stringResource(R.string.discard_changes_warning), - showDialog = showDiscardChangesDialog - ) { - state.revertToInitial() - navigation.back() - } - } } @Composable @@ -246,7 +179,7 @@ private fun ActionButtons( private fun DragQuadOverlay( state: EditPageScreenState, quadHandler: QuadEditingHandler, - bmp: android.graphics.Bitmap + bmp: Bitmap ) { if (state.editableQuad == null || state.containerSize == null) return @@ -409,22 +342,16 @@ private fun DragMagnifyingGlass(state: EditPageScreenState) { @Preview(name = "RTL", locale = "ar", showSystemUi = true) fun EditPageScreenPreview() { FairScanTheme { - - // Minimal no-op ImageTransformations implementation used only for preview. - val dummyTransformations = object : ImageTransformations { - override fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) = Unit - override fun resize(inputFile: File, outputFile: File, maxSize: Int) = Unit + val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input -> + BitmapFactory.decodeStream(input) } - - // Use a temporary directory for the repository in preview. - val tempDir = File(System.getProperty("java.io.tmpdir") ?: "/tmp") - val dummyImageRepo = ImageRepository(tempDir, dummyTransformations, 128) - + val quad = Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9)) EditPageScreen( - pageId = "preview-page-id", - imageRepository = dummyImageRepo, + pageId = "123", + onLoad = {}, + initState = CropInitState.Ready("123",dummyImage, quad), navigation = dummyNavigation(), - onUpdatePageQuad = { _, _, onComplete -> onComplete() } + onUpdatePageQuad = { _,_,_ -> }, ) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreenState.kt b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreenState.kt index 9245f0e..509da2a 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreenState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreenState.kt @@ -14,6 +14,7 @@ */ package org.fairscan.app.ui.screens.edit +import android.graphics.Bitmap import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -23,6 +24,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.IntSize import org.fairscan.imageprocessing.Quad +sealed interface CropInitState { + object Loading : CropInitState + object Error : CropInitState + data class Ready( + val pageId: String, + val bitmap: Bitmap, + val quad: Quad + ) : CropInitState +} + class EditPageScreenState { companion object { val LIFT_WIGGLE_MAX_DISTANCE = 8.dp @@ -116,12 +127,4 @@ class EditPageScreenState { initialQuad = quad editableQuad = quad } - - fun hasUnsavedChanges(): Boolean { - return editableQuad != initialQuad - } - - fun revertToInitial() { - editableQuad = initialQuad - } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f088367..90b343e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,8 +22,6 @@ Delete page Do you want to delete this page? Developer - Discard changes - You have unsaved changes. Do you want to discard them? Discard scan Downloads Error: %1$s