EditPage: New screen for editing an individual page

This commit is contained in:
Philipp Hasper
2026-02-21 14:06:01 +01:00
committed by Pierre-Yves Nicolas
parent dcc797785b
commit 2b63273168
16 changed files with 1992 additions and 6 deletions

View File

@@ -54,6 +54,8 @@ import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.edit.EditPageScreen
import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.about.AboutEvent
import org.fairscan.app.ui.screens.about.AboutScreen
@@ -62,7 +64,6 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.export.ExportActions
import org.fairscan.app.ui.screens.export.ExportEvent
import org.fairscan.app.ui.screens.export.ExportResult
@@ -177,6 +178,15 @@ 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,
navigation = navigation,
onUpdatePageQuad = { id, quad, onComplete -> },
)
}
is Screen.Main.Document -> {
DocumentScreen (
uiState = documentUiState,
@@ -457,6 +467,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)) },
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },

View File

@@ -17,6 +17,7 @@ package org.fairscan.app.ui
sealed class Screen {
sealed class Main : Screen() {
object Camera : Main()
data class EditImage(val pageIndex: Int) : Main()
data class Document(val initialPage: Int = 0) : Main()
object Export : Main()
}
@@ -29,6 +30,7 @@ sealed class Screen {
data class Navigation(
val toCameraScreen: () -> Unit,
val toEditImageScreen: (Int) -> Unit,
val toDocumentScreen: () -> Unit,
val toExportScreen: () -> Unit,
val toAboutScreen: () -> Unit,
@@ -62,6 +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.Export -> copy(stack = listOf(Screen.Main.Camera))
is Screen.Overlay -> copy(stack = stack.dropLast(1))
}

View File

@@ -25,7 +25,7 @@ import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {}, {})
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
}
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {

View File

@@ -14,6 +14,7 @@
*/
package org.fairscan.app.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -56,15 +58,18 @@ fun SecondaryActionButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
FilledIconButton (
onClick = onClick,
colors = IconButtonDefaults.outlinedIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
contentColor = MaterialTheme.colorScheme.primary
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.primary,
disabledContainerColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
),
modifier = modifier.size(40.dp)
modifier = modifier.size(40.dp),
enabled = enabled
) {
Icon(
imageVector = icon,

View File

@@ -0,0 +1,430 @@
/*
* Copyright 2026 Philipp Hasper
*
* 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.fairscan.app.ui.screens.edit
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
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
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
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,
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()
}
}
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) }
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)
}
}
val isLandscape = isLandscape(LocalConfiguration.current)
Scaffold { _ ->
Box(modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
) {
state.bitmap?.let { bmp ->
val imageBitmap = remember(bmp) { bmp.asImageBitmap() }
Box(
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center),
contentAlignment = Alignment.Center
) {
Image(
bitmap = imageBitmap,
contentDescription = "Image to edit",
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
state.containerSize = coordinates.size
},
contentScale = ContentScale.Fit,
)
DragQuadOverlay(state, quadHandler, bmp)
}
}
BackButton(
onClick = handleBack,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing)
)
AppOverflowMenu(
navigation,
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing),
)
DragMagnifyingGlass(state)
ActionButtons(
modifier = Modifier
.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter)
.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
val rotateIterations = (4 - totalRotation.value.degrees / 90) % 4
val originalQuad = quad.rotate90(rotateIterations, ImageSize(1, 1))
onUpdatePageQuad(pageId, originalQuad) {
navigation.back()
}
state.setInitialQuad(quad)
} 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
private fun ActionButtons(
modifier: Modifier,
onConfirm: () -> Unit
) {
MainActionButton(
onClick = onConfirm,
text = stringResource(R.string.confirm),
icon = Icons.Filled.Check,
iconDescription = stringResource(R.string.confirm),
modifier = modifier
)
}
@Composable
private fun DragQuadOverlay(
state: EditPageScreenState,
quadHandler: QuadEditingHandler,
bmp: android.graphics.Bitmap
) {
if (state.editableQuad == null || state.containerSize == null) return
val containerSize = state.containerSize!!
val displaySize = QuadCoordinateUtils.calculateDisplaySize(bmp.width, bmp.height, containerSize)
val liftWiggleThresholdPx = with(LocalDensity.current) {
EditPageScreenState.LIFT_WIGGLE_MAX_DISTANCE.toPx()
}
QuadOverlay(
quad = state.editableQuad!!,
containerSize = containerSize,
displaySize = displaySize,
modifier = Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { startPos ->
val quad = state.editableQuad ?: return@detectDragGestures
state.dragPosition = startPos
// Prefer the index stored at raw touch-down (exact touch position,
// before slop). Fall back to re-detecting at the slop position only
// when the raw-touch handler missed the down event.
val cornerIndex = if (state.touchDownCornerIndex >= 0) {
state.touchDownCornerIndex
} else {
quadHandler.findTouchedCorner(startPos, quad, containerSize, displaySize)
}
if (cornerIndex >= 0) {
state.startCornerDrag(cornerIndex)
}
},
onDragEnd = {
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
state.endDrag()
state.onTouchUp()
},
onDragCancel = {
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
state.endDrag()
state.onTouchUp()
},
onDrag = { change, dragAmount ->
// change.consume() is intentionally omitted: detectDragGestures
// already calls it.consume() internally after this callback returns.
state.dragPosition = change.position
val quad = state.editableQuad ?: return@detectDragGestures
state.recordDragStep(quad, dragAmount)
val normalizedDelta = QuadCoordinateUtils.screenDeltaToNormalized(
dragAmount, displaySize
)
when {
state.draggedCornerIndex >= 0 -> {
state.updateQuad(
quadHandler.updateQuadCorner(
quad, state.draggedCornerIndex, normalizedDelta
)
)
}
}
}
)
}
// Second pointer-input: fires immediately on press (before touch slop)
// so the loupe appears as soon as the finger touches a handle.
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val quad = state.editableQuad
if (quad != null) {
val cIdx = quadHandler.findTouchedCorner(down.position, quad, containerSize, displaySize)
if (cIdx >= 0) {
state.onTouchDown(down.position, cIdx)
}
}
// For a tap (no drag): waitForUpOrCancellation() sees the UP event and
// returns it, so we call onTouchUp() here.
// For a drag: detectDragGestures consumes move events, causing
// waitForUpOrCancellation() to return null. We do NOT call onTouchUp()
// here; onDragEnd / onDragCancel above handle that instead.
if (waitForUpOrCancellation() != null) {
state.onTouchUp()
}
}
}
)
}
@Composable
private fun DragMagnifyingGlass(state: EditPageScreenState) {
// showLoupe becomes true immediately on touch-down and stays true for
// one additional second after the finger is lifted.
val showLoupe = remember { mutableStateOf(false) }
// Remember the last valid focus position so the loupe keeps rendering
// correctly during the 1-second fade-out (when dragged indices are reset).
val lastKnownFocusPosition = remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(state.isTouching) {
if (state.isTouching) {
showLoupe.value = true
} else {
delay(1_000)
showLoupe.value = false
}
}
if (!showLoupe.value || state.dragPosition == null || state.containerSize == null) return
val bmp = state.bitmap ?: return
val containerSize = state.containerSize!!
val displaySize = QuadCoordinateUtils.calculateDisplaySize(
bmp.width, bmp.height, containerSize
)
val quad = state.editableQuad
// Resolve which corner index to focus on.
// Priority: active drag > pre-drag touch-down > nothing (fade-out phase).
val activeCornerIndex = state.draggedCornerIndex.takeIf { it >= 0 }
?: state.touchDownCornerIndex.takeIf { it >= 0 }
val focusPosition = if (quad != null) {
when {
activeCornerIndex != null -> {
val corner = when (activeCornerIndex) {
0 -> quad.topLeft
1 -> quad.topRight
2 -> quad.bottomRight
3 -> quad.bottomLeft
else -> null
}
corner?.let {
QuadCoordinateUtils.normalizedToScreen(it, containerSize, displaySize)
}
}
else -> null
}
} else null
// Keep the last known focus position so it's still valid after endDrag() resets the indices.
if (focusPosition != null) lastKnownFocusPosition.value = focusPosition
// On the very first touch the drag indices are not set yet and lastKnownFocusPosition
// has never been populated, so fall back to dragPosition (the finger is on the handle).
val effectiveFocusPosition = focusPosition ?: lastKnownFocusPosition.value ?: state.dragPosition ?: return
MagnifyingGlass(
bitmap = bmp,
fingerPosition = state.dragPosition!!,
focusPosition = effectiveFocusPosition,
containerSize = containerSize,
displaySize = displaySize,
quad = state.editableQuad,
)
}
@Composable
@Preview(showSystemUi = true)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, showSystemUi = true)
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
@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
}
// Use a temporary directory for the repository in preview.
val tempDir = File(System.getProperty("java.io.tmpdir") ?: "/tmp")
val dummyImageRepo = ImageRepository(tempDir, dummyTransformations, 128)
EditPageScreen(
pageId = "preview-page-id",
imageRepository = dummyImageRepo,
navigation = dummyNavigation(),
onUpdatePageQuad = { _, _, onComplete -> onComplete() }
)
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2026 Philipp Hasper
*
* 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.fairscan.app.ui.screens.edit
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Quad
class EditPageScreenState {
companion object {
val LIFT_WIGGLE_MAX_DISTANCE = 8.dp
const val LIFT_WIGGLE_WINDOW_MS = 70L
}
var bitmap by mutableStateOf<android.graphics.Bitmap?>(null)
var containerSize by mutableStateOf<IntSize?>(null)
var editableQuad by mutableStateOf<Quad?>(null)
var draggedCornerIndex by mutableIntStateOf(-1)
var dragPosition by mutableStateOf<Offset?>(null)
/** True from the moment the finger touches a drag handle until it is lifted. */
var isTouching by mutableStateOf(false)
/**
* Corner / edge index detected at the raw touch-down (before touch-slop).
* Carried into [startCornerDrag] so that the slop-adjusted
* position in onDragStart cannot miss the handle.
*/
var touchDownCornerIndex by mutableIntStateOf(-1)
private var quadBeforeDrag: Quad? = null
private var quadBeforeLastDragStep: Quad? = null
private var lastDragStepDistancePx: Float = Float.MAX_VALUE
private var lastDragStepAtMs: Long = 0L
private var initialQuad: Quad? = null
fun updateQuad(newQuad: Quad) {
editableQuad = newQuad
}
fun startCornerDrag(cornerIndex: Int) {
quadBeforeDrag = editableQuad
draggedCornerIndex = cornerIndex
clearLastDragStep()
}
fun recordDragStep(previousQuad: Quad, dragAmount: Offset, eventTimeMs: Long = System.currentTimeMillis()) {
quadBeforeLastDragStep = previousQuad
lastDragStepDistancePx = dragAmount.getDistance()
lastDragStepAtMs = eventTimeMs
}
fun rollbackLastDragStepIfLikelyLiftWiggle(
maxDistancePx: Float,
nowMs: Long = System.currentTimeMillis()
) {
if (quadBeforeLastDragStep == null) return
val isRecent = nowMs - lastDragStepAtMs <= LIFT_WIGGLE_WINDOW_MS
val isSmall = lastDragStepDistancePx <= maxDistancePx
if (isRecent && isSmall) {
editableQuad = quadBeforeLastDragStep
}
}
fun endDrag() {
quadBeforeDrag = null
clearLastDragStep()
draggedCornerIndex = -1
// dragPosition is intentionally kept so the loupe can still render
// during its 1-second fade-out after the finger is lifted.
}
private fun clearLastDragStep() {
quadBeforeLastDragStep = null
lastDragStepDistancePx = Float.MAX_VALUE
lastDragStepAtMs = 0L
}
/**
* Called as soon as the finger touches a drag handle (before touch-slop),
* so the loupe is shown immediately.
* [cornerIndex] is the handle index found at the exact
* touch position; it is stored so that drag start handling can use it even
* if the slop-adjusted position drifts outside the hit-test radius.
*/
fun onTouchDown(position: Offset, cornerIndex: Int = -1) {
isTouching = true
dragPosition = position
touchDownCornerIndex = cornerIndex
}
/** Called when the finger is lifted; triggers the loupe fade-out delay. */
fun onTouchUp() {
isTouching = false
touchDownCornerIndex = -1
}
fun isDragging(): Boolean = draggedCornerIndex >= 0
fun setInitialQuad(quad: Quad) {
initialQuad = quad
editableQuad = quad
}
fun hasUnsavedChanges(): Boolean {
return editableQuad != initialQuad
}
fun revertToInitial() {
editableQuad = initialQuad
}
}

View File

@@ -0,0 +1,311 @@
/*
* Copyright 2026 Philipp Hasper
*
* 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.fairscan.app.ui.screens.edit
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import org.fairscan.imageprocessing.Quad
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
private val LOUPE_BORDER_WIDTH = 3.dp
private const val ZOOM_FACTOR = 3f
/**
* Layout parameters for the magnifying-glass loupe.
*
* Use `LoupeLayoutConfig<Dp>` in Compose UI code and [toPx] to convert to
* `LoupeLayoutConfig<Float>` for pixel-level calculations.
*/
data class LoupeLayoutConfig<T>(
/** Radius of the loupe circle */
val loupeRadius: T,
/** Gap between the finger and the nearest edge of the loupe */
val verticalOffset: T,
/** Minimum space between the loupe and the screen edge */
val screenMargin: T,
) {
companion object {
val Default = LoupeLayoutConfig(
loupeRadius = 60.dp,
verticalOffset = 40.dp,
screenMargin = 8.dp,
)
}
}
/** Convert a dp-valued config to pixels using the current [LocalDensity]. */
@Composable
internal fun LoupeLayoutConfig<Dp>.toPx(): LoupeLayoutConfig<Float> {
val density = LocalDensity.current
return with(density) {
LoupeLayoutConfig(
loupeRadius = loupeRadius.toPx(),
verticalOffset = verticalOffset.toPx(),
screenMargin = screenMargin.toPx(),
)
}
}
/**
* Displays a magnifying glass / loupe showing a zoomed-in patch of [bitmap]
* centred around [focusPosition].
*
* Positioning rules:
* 1. By default, the loupe is placed **above** the finger.
* 2. If there is not enough room above, it moves to the **left** of the finger.
* 3. If there is not enough room on the left either, it moves to the **right**.
*
* @param bitmap The full source bitmap (original image).
* @param fingerPosition Current finger position in screen (container) coordinates, used for loupe placement.
* @param focusPosition The exact point to zoom into (e.g. corner or edge midpoint) in screen coordinates.
* @param containerSize Size of the full-screen container.
* @param displaySize Size of the image as rendered (letterboxed inside the container).
*/
@Composable
fun MagnifyingGlass(
bitmap: Bitmap,
fingerPosition: Offset,
focusPosition: Offset,
containerSize: IntSize,
displaySize: IntSize,
quad: Quad? = null,
configDp: LoupeLayoutConfig<Dp> = LoupeLayoutConfig.Default,
) {
val density = LocalDensity.current
val configPx = configDp.toPx()
val borderWidth = with(density) { LOUPE_BORDER_WIDTH.toPx() }
// compute loupe centre position
val loupeCenter = computeLoupeCenter(
dragPosition = fingerPosition,
configPx = configPx,
containerWidth = containerSize.width.toFloat(),
)
// compute the bitmap region to sample
val imageOffset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
// Focus position mapped to bitmap pixel coordinates
val bitmapX = ((focusPosition.x - imageOffset.width) / displaySize.width * bitmap.width)
.coerceIn(0f, (bitmap.width - 1).toFloat())
val bitmapY = ((focusPosition.y - imageOffset.height) / displaySize.height * bitmap.height)
.coerceIn(0f, (bitmap.height - 1).toFloat())
// How many bitmap pixels the loupe shows in each direction
val bitmapRegionHalf = (bitmap.width / displaySize.width.toFloat()) * configPx.loupeRadius / ZOOM_FACTOR
val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() }
val borderColor = MaterialTheme.colorScheme.primary
val quadLineColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
val backgroundColor = MaterialTheme.colorScheme.background
Canvas(
modifier = Modifier
.size(configDp.loupeRadius * 2)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
IntOffset(
(loupeCenter.x - configPx.loupeRadius).roundToInt(),
(loupeCenter.y - configPx.loupeRadius).roundToInt()
)
)
}
}
) {
val loupeDiameter = configPx.loupeRadius * 2
val circlePath = Path().apply {
addOval(
androidx.compose.ui.geometry.Rect(0f, 0f, loupeDiameter, loupeDiameter)
)
}
clipPath(circlePath) {
// Fill background so areas outside the image are opaque
drawRect(color = backgroundColor)
// Source rect in bitmap coordinates
val srcLeft = (bitmapX - bitmapRegionHalf).toInt().coerceAtLeast(0)
val srcTop = (bitmapY - bitmapRegionHalf).toInt().coerceAtLeast(0)
val srcRight = (bitmapX + bitmapRegionHalf).toInt().coerceAtMost(bitmap.width)
val srcBottom = (bitmapY + bitmapRegionHalf).toInt().coerceAtMost(bitmap.height)
if (srcRight > srcLeft && srcBottom > srcTop) {
// Destination offset compensate when the source rect was clamped
val dstOffsetX = ((srcLeft - (bitmapX - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
val dstOffsetY = ((srcTop - (bitmapY - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
val dstWidth = ((srcRight - srcLeft) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
val dstHeight = ((srcBottom - srcTop) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
drawImage(
image = imageBitmap,
srcOffset = IntOffset(srcLeft, srcTop),
srcSize = IntSize(srcRight - srcLeft, srcBottom - srcTop),
dstOffset = IntOffset(dstOffsetX.toInt(), dstOffsetY.toInt()),
dstSize = IntSize(dstWidth, dstHeight),
)
}
// Draw quad overlay and edges inside the loupe
// Convert each normalized quad corner to bitmap-pixel coords,
// then to loupe-local coords using the same mapping as the bitmap sampling.
if (quad != null) {
val bitmapOriginX = bitmapX - bitmapRegionHalf
val bitmapOriginY = bitmapY - bitmapRegionHalf
val scale = loupeDiameter / (2 * bitmapRegionHalf)
fun normalizedToLoupe(nx: Double, ny: Double): Offset {
val bx = (nx * bitmap.width).toFloat()
val by = (ny * bitmap.height).toFloat()
return Offset(
(bx - bitmapOriginX) * scale,
(by - bitmapOriginY) * scale
)
}
val loupeCorners = listOf(
normalizedToLoupe(quad.topLeft.x, quad.topLeft.y),
normalizedToLoupe(quad.topRight.x, quad.topRight.y),
normalizedToLoupe(quad.bottomRight.x, quad.bottomRight.y),
normalizedToLoupe(quad.bottomLeft.x, quad.bottomLeft.y),
)
// Striped overlay outside the quad to distinguish document from background
val quadPath = Path().apply {
moveTo(loupeCorners[0].x, loupeCorners[0].y)
for (corner in loupeCorners.drop(1)) {
lineTo(corner.x, corner.y)
}
close()
}
clipPath(quadPath, clipOp = ClipOp.Difference) {
// Clip to actual image bounds so stripes only appear over
// image content, not in the background area near edges.
val imgLeft = -bitmapOriginX * scale
val imgTop = -bitmapOriginY * scale
val imgRight = (bitmap.width - bitmapOriginX) * scale
val imgBottom = (bitmap.height - bitmapOriginY) * scale
clipRect(
left = imgLeft, top = imgTop,
right = imgRight, bottom = imgBottom
) {
val stripeSpacing = 50f
val stripeWidth = 10f
val stripeColor = Color.Gray.copy(alpha = 0.35f)
// Stripes are placed at fixed positions in bitmap coordinate
// space so they scroll with the image as the loupe pans.
val originShift = (bitmapOriginX + bitmapOriginY) * scale
val kMin = floor(originShift / stripeSpacing).toInt() - 1
val kMax = ceil((originShift + 2f * loupeDiameter) / stripeSpacing).toInt() + 1
for (k in kMin..kMax) {
val loupeSum = k * stripeSpacing - originShift
drawLine(
color = stripeColor,
start = Offset(loupeSum, 0f),
end = Offset(0f, loupeSum),
strokeWidth = stripeWidth,
)
}
}
}
// Draw quad edge lines on top
for (i in 0 until 4) {
drawLine(
color = quadLineColor,
start = loupeCorners[i],
end = loupeCorners[(i + 1) % 4],
strokeWidth = 3f
)
}
}
}
// Border
drawCircle(
color = borderColor,
radius = configPx.loupeRadius - borderWidth / 2,
center = Offset(configPx.loupeRadius, configPx.loupeRadius),
style = Stroke(width = borderWidth)
)
}
}
/**
* Decides where the loupe centre should be.
*
* Priority:
* 1. Above the finger (centred horizontally, clamped to screen edges).
* 2. If no vertical room -> to the left.
* 3. If no room on the left -> to the right.
*/
internal fun computeLoupeCenter(
dragPosition: Offset,
configPx: LoupeLayoutConfig<Float>,
containerWidth: Float,
): Offset {
val loupeRadius = configPx.loupeRadius
val verticalOffset = configPx.verticalOffset
val screenMargin = configPx.screenMargin
// Try above
val aboveCenterY = dragPosition.y - verticalOffset - loupeRadius
if (aboveCenterY - loupeRadius >= screenMargin) {
// Enough room above -> place centred horizontally on the finger, clamped to screen edges
val cx = dragPosition.x.coerceIn(screenMargin + loupeRadius, containerWidth - screenMargin - loupeRadius)
return Offset(cx, aboveCenterY)
}
// Not enough room above -> try left
val leftCenterX = dragPosition.x - verticalOffset - loupeRadius
if (leftCenterX - loupeRadius >= screenMargin) {
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
return Offset(leftCenterX, cy)
}
// Not enough room on the left -> place right
val rightCenterX = dragPosition.x + verticalOffset + loupeRadius
val cx = rightCenterX.coerceAtMost(containerWidth - screenMargin - loupeRadius)
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
return Offset(cx, cy)
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2026 Philipp Hasper
*
* 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.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
object QuadCoordinateUtils {
fun calculateDisplaySize(
bitmapWidth: Int,
bitmapHeight: Int,
containerSize: IntSize
): IntSize {
val imageAspectRatio = bitmapWidth.toFloat() / bitmapHeight.toFloat()
val containerAspectRatio = containerSize.width / containerSize.height.toFloat()
return if (imageAspectRatio > containerAspectRatio) {
IntSize(containerSize.width, (containerSize.width / imageAspectRatio).toInt())
} else {
IntSize((containerSize.height * imageAspectRatio).toInt(), containerSize.height)
}
}
fun normalizedToScreen(point: Point, containerSize: IntSize, displaySize: IntSize): Offset {
val offsetX = (containerSize.width - displaySize.width) / 2
val offsetY = (containerSize.height - displaySize.height) / 2
return Offset(
x = (point.x * displaySize.width).toFloat() + offsetX,
y = (point.y * displaySize.height).toFloat() + offsetY
)
}
fun screenDeltaToNormalized(delta: Offset, displaySize: IntSize): Offset {
return Offset(
x = delta.x / displaySize.width,
y = delta.y / displaySize.height
)
}
fun getImageOffset(containerSize: IntSize, displaySize: IntSize): IntSize {
return IntSize(
(containerSize.width - displaySize.width) / 2,
(containerSize.height - displaySize.height) / 2
)
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2026 Philipp Hasper
*
* 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.fairscan.app.ui.screens.edit
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
class QuadEditingHandler {
companion object {
const val CORNER_RADIUS = 40f
const val CORNER_TOUCH_RADIUS = 90f
}
fun findTouchedCorner(
touchPos: Offset,
quad: Quad,
containerSize: IntSize,
displaySize: IntSize
): Int {
return findTouchedCornerCandidates(touchPos, quad, containerSize, displaySize)
.firstOrNull() ?: -1
}
fun findTouchedCornerCandidates(
touchPos: Offset,
quad: Quad,
containerSize: IntSize,
displaySize: IntSize
): List<Int> {
val corners = getCornerPositions(quad, containerSize, displaySize)
return corners
.mapIndexed { index, corner -> index to (touchPos - corner).getDistance() }
.filter { (_, distance) -> distance < CORNER_TOUCH_RADIUS }
.sortedBy { (_, distance) -> distance }
.map { (index, _) -> index }
}
fun updateQuadCorner(quad: Quad, cornerIndex: Int, delta: Offset): Quad {
val normalizedDelta = Point(delta.x.toDouble(), delta.y.toDouble())
val candidate = when (cornerIndex) {
0 -> quad.copy(topLeft = clampPoint(quad.topLeft + normalizedDelta))
1 -> quad.copy(topRight = clampPoint(quad.topRight + normalizedDelta))
2 -> quad.copy(bottomRight = clampPoint(quad.bottomRight + normalizedDelta))
3 -> quad.copy(bottomLeft = clampPoint(quad.bottomLeft + normalizedDelta))
else -> quad
}
return if (candidate.isConvex()) candidate else quad
}
private fun getCornerPositions(quad: Quad, containerSize: IntSize, displaySize: IntSize): List<Offset> {
return listOf(
QuadCoordinateUtils.normalizedToScreen(quad.topLeft, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.topRight, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.bottomRight, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.bottomLeft, containerSize, displaySize)
)
}
private fun clampPoint(point: Point): Point {
return Point(
point.x.coerceIn(0.0, 1.0),
point.y.coerceIn(0.0, 1.0)
)
}
private operator fun Point.plus(other: Point): Point {
return Point(this.x + other.x, this.y + other.y)
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2026 Philipp Hasper
*
* 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.fairscan.app.ui.screens.edit
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.scaledTo
@Composable
fun QuadOverlay(
quad: Quad,
containerSize: IntSize,
displaySize: IntSize,
modifier: Modifier = Modifier
) {
val quadColor = MaterialTheme.colorScheme.primary
val handleColor = quadColor.copy(alpha = 0.5f)
Canvas(modifier = modifier.fillMaxSize()) {
val scaledQuad = quad.scaledTo(
fromWidth = 1,
fromHeight = 1,
toWidth = displaySize.width,
toHeight = displaySize.height
)
val offset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
val corners = listOf(
scaledQuad.topLeft.toOffset(),
scaledQuad.topRight.toOffset(),
scaledQuad.bottomRight.toOffset(),
scaledQuad.bottomLeft.toOffset()
).map { it.copy(x = it.x + offset.width, y = it.y + offset.height) }
// Draw edges
for (i in 0 until 4) {
drawLine(
color = quadColor,
start = corners[i],
end = corners[(i + 1) % 4],
strokeWidth = 10.0f
)
}
// Draw corner handles
corners.forEach { corner ->
drawCircle(
color = handleColor,
radius = QuadEditingHandler.CORNER_RADIUS,
center = corner
)
}
}
}
fun Point.toOffset() = Offset(x.toFloat(), y.toFloat())

View File

@@ -7,6 +7,7 @@
<string name="camera_permission_denied">Camera permission was denied</string>
<string name="camera_permission_rationale">The app requires camera access to scan documents. Captured images are stored only on this device and will be deleted when you close the current document.</string>
<string name="cancel">Cancel</string>
<string name="confirm">Confirm</string>
<string name="change_directory">Change folder</string>
<string name="clear_text">Clear text</string>
<string name="color_mode">Filter</string>
@@ -21,6 +22,8 @@
<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>