Plug input data to EditPageScreen

This commit is contained in:
Pierre-Yves Nicolas
2026-05-05 13:42:10 +02:00
parent 2b63273168
commit d03d411706
8 changed files with 126 additions and 120 deletions

View File

@@ -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) },

View File

@@ -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>(CropInitState.Loading)
val cropInitState: StateFlow<CropInitState> = _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)
}
}
}

View File

@@ -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<Screen>, 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))
}

View File

@@ -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 = { },

View File

@@ -29,5 +29,6 @@ data class CurrentPageUiState(
val key: PageViewKey,
val bitmap: Bitmap?,
val colorMode: ColorMode?,
val canBeCropped: Boolean = false,
val isLoading: Boolean = false,
)

View File

@@ -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 = { _,_,_ -> },
)
}
}

View File

@@ -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
}
}

View File

@@ -22,8 +22,6 @@
<string name="delete_page">Delete page</string>
<string name="delete_page_warning">Do you want to delete this page?</string>
<string name="developer">Developer</string>
<string name="discard_changes">Discard changes</string>
<string name="discard_changes_warning">You have unsaved changes. Do you want to discard them?</string>
<string name="discard_scan">Discard scan</string>
<string name="download_dirname">Downloads</string>
<string name="error">Error: %1$s</string>