diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index f048484..1caeefe 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -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) }, 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 a6d7741..a398811 100644 --- a/app/src/main/java/org/fairscan/app/ui/Navigation.kt +++ b/app/src/main/java/org/fairscan/app/ui/Navigation.kt @@ -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, 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)) } diff --git a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt index 048abc0..97a74fc 100644 --- a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt @@ -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, context: Context): DocumentUiModel { diff --git a/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt b/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt index 86c15d5..e8da2fc 100644 --- a/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt +++ b/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt @@ -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, 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 new file mode 100644 index 0000000..dad591a --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreen.kt @@ -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 . + */ +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(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() } + ) + } +} 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 new file mode 100644 index 0000000..9245f0e --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/EditPageScreenState.kt @@ -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 . + */ +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(null) + var containerSize by mutableStateOf(null) + var editableQuad by mutableStateOf(null) + var draggedCornerIndex by mutableIntStateOf(-1) + var dragPosition by mutableStateOf(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 + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/edit/MagnifyingGlass.kt b/app/src/main/java/org/fairscan/app/ui/screens/edit/MagnifyingGlass.kt new file mode 100644 index 0000000..a3d6404 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/MagnifyingGlass.kt @@ -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 . + */ +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` in Compose UI code and [toPx] to convert to + * `LoupeLayoutConfig` for pixel-level calculations. + */ +data class LoupeLayoutConfig( + /** 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.toPx(): LoupeLayoutConfig { + 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 = 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, + 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) +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadCoordinateUtils.kt b/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadCoordinateUtils.kt new file mode 100644 index 0000000..f0720f8 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadCoordinateUtils.kt @@ -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 . + */ +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 + ) + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadEditingHandler.kt b/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadEditingHandler.kt new file mode 100644 index 0000000..61a0c90 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadEditingHandler.kt @@ -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 . + */ +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 { + 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 { + 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) + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadOverlay.kt b/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadOverlay.kt new file mode 100644 index 0000000..1bd8339 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/edit/QuadOverlay.kt @@ -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 . + */ +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()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 951ba30..f088367 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Camera permission was denied 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. Cancel + Confirm Change folder Clear text Filter @@ -21,6 +22,8 @@ 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 diff --git a/app/src/test/java/org/fairscan/app/ui/screens/edit/EditPageScreenStateTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/edit/EditPageScreenStateTest.kt new file mode 100644 index 0000000..a1e6be6 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/edit/EditPageScreenStateTest.kt @@ -0,0 +1,356 @@ +/* + * 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 . + */ +package org.fairscan.app.ui.screens.edit + +import androidx.compose.ui.geometry.Offset +import org.assertj.core.api.Assertions.assertThat +import org.fairscan.imageprocessing.Point +import org.fairscan.imageprocessing.Quad +import org.junit.Test + +class EditPageScreenStateTest { + + companion object { + private const val wiggleThresholdPx = 8f + } + + private val testQuad = Quad( + topLeft = Point(0.1, 0.1), + topRight = Point(0.9, 0.1), + bottomRight = Point(0.9, 0.9), + bottomLeft = Point(0.1, 0.9) + ) + + private val updatedQuad = Quad( + topLeft = Point(0.2, 0.2), + topRight = Point(0.8, 0.2), + bottomRight = Point(0.8, 0.8), + bottomLeft = Point(0.2, 0.8) + ) + + @Test + fun initialState_hasCorrectDefaults() { + val state = EditPageScreenState() + + assertThat(state.bitmap).isNull() + assertThat(state.containerSize).isNull() + assertThat(state.editableQuad).isNull() + assertThat(state.draggedCornerIndex).isEqualTo(-1) + assertThat(state.isDragging()).isFalse() + // Touch / loupe state + assertThat(state.isTouching).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + assertThat(state.dragPosition).isNull() + } + + @Test + fun quadUpdates_workCorrectly() { + val state = EditPageScreenState() + + state.updateQuad(testQuad) + assertThat(state.editableQuad).isEqualTo(testQuad) + + state.updateQuad(updatedQuad) + assertThat(state.editableQuad).isEqualTo(updatedQuad) + } + + @Test + fun cornerDragging_managesStateCorrectly() { + val state = EditPageScreenState() + + // Corner drag starts correctly + for (i in 0 until 4) { + state.startCornerDrag(i) + assertThat(state.draggedCornerIndex).isEqualTo(i) + assertThat(state.isDragging()).isTrue() + } + + // End drag resets all + state.startCornerDrag(2) + state.endDrag() + assertThat(state.draggedCornerIndex).isEqualTo(-1) + assertThat(state.isDragging()).isFalse() + + // End drag when not dragging stays in non-dragging state + state.endDrag() + assertThat(state.isDragging()).isFalse() + assertThat(state.draggedCornerIndex).isEqualTo(-1) + } + + @Test + fun fullDragCycle_preservesQuadAfterDragEnds() { + val state = EditPageScreenState() + + assertThat(state.isDragging()).isFalse() + state.startCornerDrag(1) + assertThat(state.isDragging()).isTrue() + state.updateQuad(updatedQuad) + assertThat(state.editableQuad).isEqualTo(updatedQuad) + assertThat(state.isDragging()).isTrue() + state.endDrag() + assertThat(state.isDragging()).isFalse() + assertThat(state.editableQuad).isEqualTo(updatedQuad) + } + + // ── onTouchDown ────────────────────────────────────────────────────────── + + @Test + fun onTouchDown_setsIsTouchingAndDragPosition() { + val state = EditPageScreenState() + val pos = Offset(100f, 200f) + + state.onTouchDown(pos) + + assertThat(state.isTouching).isTrue() + assertThat(state.dragPosition).isEqualTo(pos) + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + } + + @Test + fun onTouchDown_withCornerIndex_storesCornerIndex() { + val state = EditPageScreenState() + + state.onTouchDown(Offset(50f, 50f), cornerIndex = 2) + + assertThat(state.isTouching).isTrue() + assertThat(state.touchDownCornerIndex).isEqualTo(2) + } + + @Test + fun onTouchDown_withEdgeIndex_storesEdgeIndex() { + // Edge index no longer exists; onTouchDown with no corner index leaves touchDownCornerIndex as -1. + val state = EditPageScreenState() + + state.onTouchDown(Offset(50f, 50f)) + + assertThat(state.isTouching).isTrue() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + } + + @Test + fun onTouchDown_overwritesPreviousTouchDown() { + val state = EditPageScreenState() + state.onTouchDown(Offset(10f, 10f), cornerIndex = 0) + + state.onTouchDown(Offset(50f, 50f), cornerIndex = 3) + + assertThat(state.dragPosition).isEqualTo(Offset(50f, 50f)) + assertThat(state.touchDownCornerIndex).isEqualTo(3) + } + + // ── onTouchUp ──────────────────────────────────────────────────────────── + + @Test + fun onTouchUp_clearsIsTouchingAndTouchDownIndices() { + val state = EditPageScreenState() + state.onTouchDown(Offset(100f, 200f), cornerIndex = 1) + + state.onTouchUp() + + assertThat(state.isTouching).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + } + + @Test + fun onTouchUp_preservesDragPosition() { + val state = EditPageScreenState() + val pos = Offset(100f, 200f) + state.onTouchDown(pos, cornerIndex = 1) + + state.onTouchUp() + + // dragPosition must survive so the loupe can still render during its fade-out delay. + assertThat(state.dragPosition).isEqualTo(pos) + } + + @Test + fun onTouchUp_whenNotTouching_isIdempotent() { + val state = EditPageScreenState() + + state.onTouchUp() + + assertThat(state.isTouching).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + } + + // ── endDrag ────────────────────────────────────────────────────────────── + + @Test + fun endDrag_preservesDragPosition() { + val state = EditPageScreenState() + state.setInitialQuad(testQuad) + val pos = Offset(100f, 200f) + state.onTouchDown(pos, cornerIndex = 0) + state.startCornerDrag(0) + state.updateQuad(updatedQuad) + + state.endDrag() + + // dragPosition must NOT be nulled so the loupe stays visible during the 1 s fade-out. + assertThat(state.dragPosition).isEqualTo(pos) + assertThat(state.draggedCornerIndex).isEqualTo(-1) + } + + @Test + fun endDrag_doesNotResetTouchDownIndices() { + val state = EditPageScreenState() + state.setInitialQuad(testQuad) + state.onTouchDown(Offset(100f, 200f), cornerIndex = 2) + state.startCornerDrag(2) + + state.endDrag() + + // touchDownCornerIndex is owned by onTouchUp(), not endDrag(). + assertThat(state.touchDownCornerIndex).isEqualTo(2) + } + + @Test + fun rollbackLastDragStepIfLikelyLiftWiggle_revertsRecentSmallStep() { + val state = EditPageScreenState() + state.updateQuad(testQuad) + state.startCornerDrag(0) + + state.recordDragStep(testQuad, Offset(3f, 2f), eventTimeMs = 1_000) + state.updateQuad(updatedQuad) + + state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_030) + + assertThat(state.editableQuad).isEqualTo(testQuad) + } + + @Test + fun rollbackLastDragStepIfLikelyLiftWiggle_keepsLargeStep() { + val state = EditPageScreenState() + state.updateQuad(testQuad) + state.startCornerDrag(0) + + state.recordDragStep(testQuad, Offset(20f, 0f), eventTimeMs = 1_000) + state.updateQuad(updatedQuad) + + state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_030) + + assertThat(state.editableQuad).isEqualTo(updatedQuad) + } + + @Test + fun rollbackLastDragStepIfLikelyLiftWiggle_keepsOldSmallStep() { + val state = EditPageScreenState() + state.updateQuad(testQuad) + state.startCornerDrag(0) + + state.recordDragStep(testQuad, Offset(3f, 2f), eventTimeMs = 1_000) + state.updateQuad(updatedQuad) + + state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_200) + + assertThat(state.editableQuad).isEqualTo(updatedQuad) + } + + @Test + fun endDrag_clearsLastDragStepTracking() { + val state = EditPageScreenState() + state.updateQuad(testQuad) + state.startCornerDrag(0) + + state.recordDragStep(testQuad, Offset(3f, 2f), eventTimeMs = 1_000) + state.updateQuad(updatedQuad) + state.endDrag() + + state.rollbackLastDragStepIfLikelyLiftWiggle(wiggleThresholdPx, nowMs = 1_010) + + assertThat(state.editableQuad).isEqualTo(updatedQuad) + } + + // ── full interaction cycles ─────────────────────────────────────────────── + + @Test + fun tapCycle_leavesStateConsistent() { + val state = EditPageScreenState() + val pos = Offset(100f, 200f) + + state.onTouchDown(pos, cornerIndex = 3) + assertThat(state.isTouching).isTrue() + assertThat(state.touchDownCornerIndex).isEqualTo(3) + + state.onTouchUp() + + assertThat(state.isTouching).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + assertThat(state.dragPosition).isEqualTo(pos) // preserved for loupe fade-out + assertThat(state.isDragging()).isFalse() + } + + @Test + fun dragCycle_corner_leavesStateConsistent() { + val state = EditPageScreenState() + state.setInitialQuad(testQuad) + val pos = Offset(100f, 200f) + + state.onTouchDown(pos, cornerIndex = 1) + state.startCornerDrag(1) + assertThat(state.isDragging()).isTrue() + assertThat(state.isTouching).isTrue() + assertThat(state.draggedCornerIndex).isEqualTo(1) + assertThat(state.touchDownCornerIndex).isEqualTo(1) + + state.updateQuad(updatedQuad) + state.endDrag() + state.onTouchUp() + + assertThat(state.isDragging()).isFalse() + assertThat(state.isTouching).isFalse() + assertThat(state.draggedCornerIndex).isEqualTo(-1) + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + assertThat(state.dragPosition).isEqualTo(pos) // preserved for loupe fade-out + assertThat(state.editableQuad).isEqualTo(updatedQuad) + } + + @Test + fun dragCycle_edge_leavesStateConsistent() { + // Edge dragging is no longer supported; this test verifies that a touch + // without a valid corner index simply does not trigger a drag. + val state = EditPageScreenState() + state.setInitialQuad(testQuad) + val pos = Offset(150f, 80f) + + state.onTouchDown(pos) + assertThat(state.isDragging()).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + + state.onTouchUp() + + assertThat(state.isDragging()).isFalse() + assertThat(state.isTouching).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + assertThat(state.dragPosition).isEqualTo(pos) + } + + @Test + fun consecutiveTaps_eachSetsCorrectTouchDownIndex() { + val state = EditPageScreenState() + + state.onTouchDown(Offset(10f, 10f), cornerIndex = 0) + assertThat(state.touchDownCornerIndex).isEqualTo(0) + state.onTouchUp() + + state.onTouchDown(Offset(90f, 10f), cornerIndex = 1) + assertThat(state.touchDownCornerIndex).isEqualTo(1) + state.onTouchUp() + + state.onTouchDown(Offset(90f, 90f), cornerIndex = 2) + assertThat(state.touchDownCornerIndex).isEqualTo(2) + state.onTouchUp() + } +} diff --git a/app/src/test/java/org/fairscan/app/ui/screens/edit/MagnifyingGlassTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/edit/MagnifyingGlassTest.kt new file mode 100644 index 0000000..2ae3237 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/edit/MagnifyingGlassTest.kt @@ -0,0 +1,156 @@ +/* + * 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 . + */ +package org.fairscan.app.ui.screens.edit + +import androidx.compose.ui.geometry.Offset +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +/** Acceptable offset for float comparisons **/ +private val FLOAT_OFFSET = org.assertj.core.data.Offset.offset(0.0001f) + +class MagnifyingGlassTest { + + private val configPx = LoupeLayoutConfig( + loupeRadius = 150f, + verticalOffset = 200f, + screenMargin = 20f, + ) + private val containerWidth = 1080f + + @Test + fun abovePlacement_whenPlentyOfRoomAbove() { + // Finger in the middle of the screen, plenty of room everywhere + val drag = Offset(540f, 800f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Expected Y: 800 - 200 - 150 = 450 + assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET) + // X should stay centred on finger + assertThat(result.x).isCloseTo(540f, FLOAT_OFFSET) + } + + @Test + fun abovePlacement_clampsXToLeftEdge() { + // Finger very close to the left edge + val drag = Offset(50f, 800f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // X should be clamped to screenMargin + loupeRadius = 20 + 150 = 170 + assertThat(result.x).isCloseTo(170f, FLOAT_OFFSET) + // Still placed above + assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET) + } + + @Test + fun abovePlacement_clampsXToRightEdge() { + // Finger very close to the right edge + val drag = Offset(1060f, 800f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // X should be clamped to containerWidth - screenMargin - loupeRadius = 1080 - 20 - 150 = 910 + assertThat(result.x).isCloseTo(910f, FLOAT_OFFSET) + // Still placed above + assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET) + } + + @Test + fun leftPlacement_whenNotEnoughRoomAbove() { + // Finger near the top: Y must be small enough that above placement fails + // above center Y = dragY - verticalOffset - loupeRadius + // condition: aboveCenterY - loupeRadius >= screenMargin + // => dragY - 200 - 150 - 150 >= 20 => dragY >= 520 + // Use dragY = 300 (not enough room above) + // Finger at centre-X so there IS room to the left + val drag = Offset(540f, 300f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Left center X = 540 - 200 - 150 = 190 + // leftCenterX - loupeRadius = 190 - 150 = 40 >= 20 ✓ + assertThat(result.x).isCloseTo(190f, FLOAT_OFFSET) + // Y should equal dragY (clamped, but 300 > screenMargin + loupeRadius = 170) + assertThat(result.y).isCloseTo(300f, FLOAT_OFFSET) + } + + @Test + fun leftPlacement_clampsYToTopEdge() { + // Finger at very top and far right (no room above, room to left) + val drag = Offset(540f, 100f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Left placement: X = 540 - 200 - 150 = 190 (room check: 190 - 150 = 40 >= 20 ✓) + assertThat(result.x).isCloseTo(190f, FLOAT_OFFSET) + // Y clamped to screenMargin + loupeRadius = 20 + 150 = 170 + assertThat(result.y).isCloseTo(170f, FLOAT_OFFSET) + } + + @Test + fun rightPlacement_whenNoRoomAboveOrLeft() { + // Finger near top-left corner: not enough room above AND not enough room on the left + // For left to fail: leftCenterX - loupeRadius < screenMargin + // leftCenterX = dragX - 200 - 150 = dragX - 350 + // leftCenterX - 150 = dragX - 500 < 20 => dragX < 520 + // Also not enough room above: dragY < 520 + val drag = Offset(100f, 300f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Right center X = 100 + 200 + 150 = 450 + // Clamped: min(450, 1080 - 20 - 150) = min(450, 910) = 450 + assertThat(result.x).isCloseTo(450f, FLOAT_OFFSET) + // Y should equal dragY (300 > 170) + assertThat(result.y).isCloseTo(300f, FLOAT_OFFSET) + } + + @Test + fun rightPlacement_clampsXToRightEdge() { + // Use a narrow container to force right placement with X clamping + val narrowResult = computeLoupeCenter( + Offset(100f, 300f), configPx, 500f + ) + + // Right center X = 100 + 200 + 150 = 450 + // Clamped: min(450, 500 - 20 - 150) = min(450, 330) = 330 + assertThat(narrowResult.x).isCloseTo(330f, FLOAT_OFFSET) + assertThat(narrowResult.y).isCloseTo(300f, FLOAT_OFFSET) + } + + @Test + fun rightPlacement_clampsYToTopEdge() { + // Finger at extreme top-left: Y very small, no room above/left -> right, Y clamped + val drag = Offset(50f, 50f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // above: 50 - 200 - 150 = -300, -300 - 150 = -450 < 20 ✗ + // left: 50 - 200 - 150 = -300, -300 - 150 = -450 < 20 ✗ + // right: 50 + 200 + 150 = 400 + assertThat(result.x).isCloseTo(400f, FLOAT_OFFSET) + // Y clamped to screenMargin + loupeRadius = 170 + assertThat(result.y).isCloseTo(170f, FLOAT_OFFSET) + } + + @Test + fun abovePlacement_exactBoundary() { + // dragY such that aboveCenterY - loupeRadius == screenMargin exactly + // dragY - verticalOffset - loupeRadius - loupeRadius = screenMargin + // dragY = screenMargin + 2*loupeRadius + verticalOffset = 20 + 300 + 200 = 520 + val drag = Offset(540f, 520f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Should still place above (condition uses >=) + val expectedY = 520f - 200f - 150f // = 170 + assertThat(result.y).isCloseTo(expectedY, FLOAT_OFFSET) + assertThat(result.x).isCloseTo(540f, FLOAT_OFFSET) + } +} diff --git a/app/src/test/java/org/fairscan/app/ui/screens/edit/QuadCoordinateUtilsTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/edit/QuadCoordinateUtilsTest.kt new file mode 100644 index 0000000..45762a7 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/edit/QuadCoordinateUtilsTest.kt @@ -0,0 +1,113 @@ +/* + * 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 . + */ +package org.fairscan.app.ui.screens.edit + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset as AssertJOffset +import org.fairscan.imageprocessing.Point +import org.junit.Test + +class QuadCoordinateUtilsTest { + + @Test + fun calculateDisplaySize_scalesCorrectlyForVariousAspectRatios() { + // Wider image than container - fits to width + var result = QuadCoordinateUtils.calculateDisplaySize(1920, 1080, IntSize(1000, 800)) + assertThat(result.width).isEqualTo(1000) + assertThat(result.height).isCloseTo(562, AssertJOffset.offset(1)) + + // Taller image than container - fits to height + result = QuadCoordinateUtils.calculateDisplaySize(1080, 1920, IntSize(1000, 800)) + assertThat(result.height).isEqualTo(800) + assertThat(result.width).isCloseTo(450, AssertJOffset.offset(1)) + + // Square image in square container + result = QuadCoordinateUtils.calculateDisplaySize(500, 500, IntSize(1000, 1000)) + assertThat(result.width).isEqualTo(1000) + assertThat(result.height).isEqualTo(1000) + + // Same aspect ratio - fills container + result = QuadCoordinateUtils.calculateDisplaySize(800, 600, IntSize(400, 300)) + assertThat(result.width).isEqualTo(400) + assertThat(result.height).isEqualTo(300) + } + + @Test + fun normalizedToScreen_convertsPointsWithCorrectOffset() { + val containerSize = IntSize(1000, 800) + val displaySize = IntSize(800, 600) + // Offset: (1000-800)/2 = 100 for x, (800-600)/2 = 100 for y + + // Top-left corner (0,0) + var result = QuadCoordinateUtils.normalizedToScreen(Point(0.0, 0.0), containerSize, displaySize) + assertThat(result.x).isCloseTo(100f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(100f, AssertJOffset.offset(0.1f)) + + // Center (0.5, 0.5) -> x: 0.5*800+100=500, y: 0.5*600+100=400 + result = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize) + assertThat(result.x).isCloseTo(500f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(400f, AssertJOffset.offset(0.1f)) + + // Bottom-right corner (1,1) -> x: 1.0*800+100=900, y: 1.0*600+100=700 + result = QuadCoordinateUtils.normalizedToScreen(Point(1.0, 1.0), containerSize, displaySize) + assertThat(result.x).isCloseTo(900f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(700f, AssertJOffset.offset(0.1f)) + + // No offset when sizes match + result = QuadCoordinateUtils.normalizedToScreen(Point(0.0, 0.0), IntSize(800, 600), IntSize(800, 600)) + assertThat(result.x).isCloseTo(0f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(0f, AssertJOffset.offset(0.1f)) + } + + @Test + fun screenDeltaToNormalized_convertsDeltas() { + val displaySize = IntSize(800, 600) + + // Positive delta: 80/800=0.1, 60/600=0.1 + var result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(80f, 60f), displaySize) + assertThat(result.x).isCloseTo(0.1f, AssertJOffset.offset(0.001f)) + assertThat(result.y).isCloseTo(0.1f, AssertJOffset.offset(0.001f)) + + // Zero delta + result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(0f, 0f), displaySize) + assertThat(result.x).isEqualTo(0f) + assertThat(result.y).isEqualTo(0f) + + // Negative delta: -160/800=-0.2, -120/600=-0.2 + result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(-160f, -120f), displaySize) + assertThat(result.x).isCloseTo(-0.2f, AssertJOffset.offset(0.001f)) + assertThat(result.y).isCloseTo(-0.2f, AssertJOffset.offset(0.001f)) + } + + @Test + fun getImageOffset_calculatesCorrectOffsets() { + // Standard offset + var result = QuadCoordinateUtils.getImageOffset(IntSize(1000, 800), IntSize(800, 600)) + assertThat(result.width).isEqualTo(100) + assertThat(result.height).isEqualTo(100) + + // Same size - zero offset + result = QuadCoordinateUtils.getImageOffset(IntSize(800, 600), IntSize(800, 600)) + assertThat(result.width).isEqualTo(0) + assertThat(result.height).isEqualTo(0) + + // Asymmetric offset (only horizontal) + result = QuadCoordinateUtils.getImageOffset(IntSize(1000, 600), IntSize(800, 600)) + assertThat(result.width).isEqualTo(100) + assertThat(result.height).isEqualTo(0) + } +} diff --git a/app/src/test/java/org/fairscan/app/ui/screens/edit/QuadEditingHandlerTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/edit/QuadEditingHandlerTest.kt new file mode 100644 index 0000000..3fdb094 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/edit/QuadEditingHandlerTest.kt @@ -0,0 +1,230 @@ +/* + * 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 . + */ +package org.fairscan.app.ui.screens.edit + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset as AssertJOffset +import org.fairscan.imageprocessing.Point +import org.fairscan.imageprocessing.Quad +import org.junit.Before +import org.junit.Test + +class QuadEditingHandlerTest { + + private lateinit var handler: QuadEditingHandler + private val containerSize = IntSize(1000, 800) + private val displaySize = IntSize(800, 600) + + private val centeredQuad = Quad( + topLeft = Point(0.2, 0.2), + topRight = Point(0.8, 0.2), + bottomRight = Point(0.8, 0.8), + bottomLeft = Point(0.2, 0.8) + ) + + @Before + fun setUp() { + handler = QuadEditingHandler() + } + + private fun closeTopCornersQuadForTouchRadius(): Quad { + val radiusPx = QuadEditingHandler.CORNER_TOUCH_RADIUS + // Keep the two top corners comfortably inside each other's touch radius. + val separationPx = radiusPx * 0.8f + val normalizedHalfSeparation = (separationPx / displaySize.width) / 2.0 + val centerX = 0.5 + return Quad( + topLeft = Point(centerX - normalizedHalfSeparation, 0.2), + topRight = Point(centerX + normalizedHalfSeparation, 0.2), + bottomRight = Point(0.8, 0.8), + bottomLeft = Point(0.2, 0.8) + ) + } + + @Test + fun findTouchedCorner_detectsAllCornersAndMisses() { + // All four corners should be detected + val corners = listOf(centeredQuad.topLeft, centeredQuad.topRight, centeredQuad.bottomRight, centeredQuad.bottomLeft) + corners.forEachIndexed { index, corner -> + val touchPos = QuadCoordinateUtils.normalizedToScreen(corner, containerSize, displaySize) + assertThat(handler.findTouchedCorner(touchPos, centeredQuad, containerSize, displaySize)).isEqualTo(index) + } + + // Near corner (within radius) should also be detected + val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(centeredQuad.topLeft, containerSize, displaySize) + val nearTouch = Offset(topLeftScreen.x + 20f, topLeftScreen.y + 15f) + assertThat(handler.findTouchedCorner(nearTouch, centeredQuad, containerSize, displaySize)).isEqualTo(0) + + // Outside visual corner radius but inside expanded touch radius. + val outsideVisualButTouchable = Offset(topLeftScreen.x + 70f, topLeftScreen.y) + assertThat((outsideVisualButTouchable - topLeftScreen).getDistance()) + .isGreaterThan(QuadEditingHandler.CORNER_RADIUS) + assertThat(handler.findTouchedCorner(outsideVisualButTouchable, centeredQuad, containerSize, displaySize)) + .isEqualTo(0) + + // Far from corners should return -1 + val centerTouch = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize) + assertThat(handler.findTouchedCorner(centerTouch, centeredQuad, containerSize, displaySize)).isEqualTo(-1) + } + + @Test + fun findTouchedCorner_selectsClosestCornerWhenMultipleAreInTouchRadius() { + // Two corners close together so their touch areas overlap. + val closeTopCornersQuad = closeTopCornersQuadForTouchRadius() + val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topLeft, containerSize, displaySize) + val topRightScreen = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topRight, containerSize, displaySize) + val towardCornerOffset = QuadEditingHandler.CORNER_TOUCH_RADIUS * 0.2f + + // Touch a bit to the left of topRight — inside both radii but closer to topRight (index 1). + val touchCloserToTopRight = Offset(topRightScreen.x - towardCornerOffset, topRightScreen.y) + assertThat(handler.findTouchedCorner(touchCloserToTopRight, closeTopCornersQuad, containerSize, displaySize)) + .isEqualTo(1) + + // Touch a bit to the right of topLeft — inside both radii but closer to topLeft (index 0). + val touchCloserToTopLeft = Offset(topLeftScreen.x + towardCornerOffset, topLeftScreen.y) + assertThat(handler.findTouchedCorner(touchCloserToTopLeft, closeTopCornersQuad, containerSize, displaySize)) + .isEqualTo(0) + + // Exact midpoint — equal distance to both; either index 0 or 1 is acceptable. + val midpointTouch = Offset((topLeftScreen.x + topRightScreen.x) / 2f, topLeftScreen.y) + assertThat(handler.findTouchedCorner(midpointTouch, closeTopCornersQuad, containerSize, displaySize)) + .isIn(0, 1) + } + + @Test + fun findTouchedCornerCandidates_returnsAllCornersInRadiusSortedByDistance() { + // Single corner in range: only that corner returned. + val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(centeredQuad.topLeft, containerSize, displaySize) + val single = handler.findTouchedCornerCandidates(topLeftScreen, centeredQuad, containerSize, displaySize) + assertThat(single).containsExactly(0) + + // No corner in range: empty list. + val farAway = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize) + assertThat(handler.findTouchedCornerCandidates(farAway, centeredQuad, containerSize, displaySize)).isEmpty() + + // Use a quad whose top two corners are spaced relative to CORNER_TOUCH_RADIUS. + val closeTopCornersQuad = closeTopCornersQuadForTouchRadius() + val closeTL = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topLeft, containerSize, displaySize) + val closeTR = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topRight, containerSize, displaySize) + val towardCornerOffset = QuadEditingHandler.CORNER_TOUCH_RADIUS * 0.2f + + // Touch at midpoint: both in range, order may vary — but both must be present. + val midpoint = Offset((closeTL.x + closeTR.x) / 2f, closeTL.y) + val bothCandidates = handler.findTouchedCornerCandidates(midpoint, closeTopCornersQuad, containerSize, displaySize) + assertThat(bothCandidates).containsExactlyInAnyOrder(0, 1) + + // Touch closer to topRight (index 1): topRight must be first in the list. + val touchNearTR = Offset(closeTR.x - towardCornerOffset, closeTR.y) + val overlap = handler.findTouchedCornerCandidates(touchNearTR, closeTopCornersQuad, containerSize, displaySize) + assertThat(overlap.first()).isEqualTo(1) // topRight is closest + assertThat(overlap).contains(0) // topLeft also a candidate + + // Touch closer to topLeft (index 0): topLeft must be first. + val touchNearTL = Offset(closeTL.x + towardCornerOffset, closeTL.y) + val overlapTL = handler.findTouchedCornerCandidates(touchNearTL, closeTopCornersQuad, containerSize, displaySize) + assertThat(overlapTL.first()).isEqualTo(0) + } + + @Test + fun updateQuadCorner_movesCorrectCornerAndClampsTooBounds() { + // Move each corner and verify only that corner changes + val deltas = listOf(Offset(0.1f, 0.1f), Offset(-0.1f, 0.1f), Offset(-0.1f, -0.1f), Offset(0.1f, -0.1f)) + val expectedPositions = listOf(Point(0.3, 0.3), Point(0.7, 0.3), Point(0.7, 0.7), Point(0.3, 0.7)) + + deltas.forEachIndexed { index, delta -> + val result = handler.updateQuadCorner(centeredQuad, index, delta) + val movedCorner = when (index) { + 0 -> result.topLeft + 1 -> result.topRight + 2 -> result.bottomRight + else -> result.bottomLeft + } + assertThat(movedCorner.x).isCloseTo(expectedPositions[index].x, AssertJOffset.offset(0.001)) + assertThat(movedCorner.y).isCloseTo(expectedPositions[index].y, AssertJOffset.offset(0.001)) + } + + // Invalid index returns unchanged quad + assertThat(handler.updateQuadCorner(centeredQuad, 5, Offset(0.1f, 0.1f))).isEqualTo(centeredQuad) + + // Zero delta returns unchanged quad + assertThat(handler.updateQuadCorner(centeredQuad, 0, Offset(0f, 0f))).isEqualTo(centeredQuad) + + // Clamping to min bounds (0) + var result = handler.updateQuadCorner(centeredQuad, 0, Offset(-0.5f, -0.5f)) + assertThat(result.topLeft.x).isEqualTo(0.0) + assertThat(result.topLeft.y).isEqualTo(0.0) + + // Clamping to max bounds (1) + result = handler.updateQuadCorner(centeredQuad, 2, Offset(0.5f, 0.5f)) + assertThat(result.bottomRight.x).isEqualTo(1.0) + assertThat(result.bottomRight.y).isEqualTo(1.0) + } + + + // ── Convexity enforcement tests ────────────────────────────────────── + + @Test + fun updateQuadCorner_rejectsConcaveResult() { + // Drag the topLeft corner past the diagonal to create a concave quad. + // Moving topLeft far to the right and down should make the quad concave. + val result = handler.updateQuadCorner(centeredQuad, 0, Offset(0.7f, 0.7f)) + // The result should still be convex, meaning the move was rejected + // (the original quad is returned). + assertThat(result).isEqualTo(centeredQuad) + } + + @Test + fun updateQuadCorner_allowsConvexResult() { + // A small move that keeps the quad convex should be allowed. + val result = handler.updateQuadCorner(centeredQuad, 0, Offset(0.05f, 0.05f)) + // The corner should have moved. + assertThat(result.topLeft.x).isCloseTo(0.25, AssertJOffset.offset(0.001)) + assertThat(result.topLeft.y).isCloseTo(0.25, AssertJOffset.offset(0.001)) + } + + + @Test + fun updateQuadCorner_allowsFixingConcaveQuad() { + // Start with a concave quad (topLeft is pushed too far inward). + val concaveQuad = Quad( + topLeft = Point(0.7, 0.7), // past the center, making it concave + topRight = Point(0.8, 0.2), + bottomRight = Point(0.8, 0.8), + bottomLeft = Point(0.2, 0.8) + ) + // Move topLeft back outward to restore convexity. + val result = handler.updateQuadCorner(concaveQuad, 0, Offset(-0.5f, -0.5f)) + // The move should be allowed because the result is convex. + assertThat(result.topLeft.x).isCloseTo(0.2, AssertJOffset.offset(0.001)) + assertThat(result.topLeft.y).isCloseTo(0.2, AssertJOffset.offset(0.001)) + } + + + @Test + fun updateQuadCorner_rejectsMoveAlongEdgeThatCreatesConcavity() { + // Start with a nearly-flat quad that's still convex. + val narrowQuad = Quad( + topLeft = Point(0.2, 0.2), + topRight = Point(0.8, 0.2), + bottomRight = Point(0.8, 0.3), + bottomLeft = Point(0.2, 0.3) + ) + // Drag bottomLeft upward past the top edge — should be rejected. + val result = handler.updateQuadCorner(narrowQuad, 3, Offset(0.0f, -0.2f)) + assertThat(result).isEqualTo(narrowQuad) + } +} diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt index fc6bf2d..0ad1603 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt @@ -47,6 +47,26 @@ data class Quad( Line(bottomLeft, topLeft)) } + /** + * Returns `true` when the four corners form a strictly convex polygon with + * correct winding order (topLeft -> topRight -> bottomRight -> bottomLeft + * clockwise in screen coordinates where y increases downward). + * + * The check computes the cross product at each corner of consecutive edges + * and verifies that all four cross products are strictly positive. + */ + fun isConvex(): Boolean { + val pts = listOf(topLeft, topRight, bottomRight, bottomLeft) + for (i in pts.indices) { + val a = pts[i] + val b = pts[(i + 1) % 4] + val c = pts[(i + 2) % 4] + val cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) + if (cross <= 0.0) return false + } + return true + } + fun rotate90(iterations: Int, imageSize: ImageSize): Quad { val rotatedPoints = listOf( rotate90(topLeft, imageSize, iterations),