EditPage: New screen for editing an individual page
This commit is contained in:
committed by
Pierre-Yves Nicolas
parent
dcc797785b
commit
2b63273168
@@ -54,6 +54,8 @@ import org.fairscan.app.data.ImageRepository
|
|||||||
import org.fairscan.app.ui.Navigation
|
import org.fairscan.app.ui.Navigation
|
||||||
import org.fairscan.app.ui.Screen
|
import org.fairscan.app.ui.Screen
|
||||||
import org.fairscan.app.ui.components.rememberCameraPermissionState
|
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.LibrariesScreen
|
||||||
import org.fairscan.app.ui.screens.about.AboutEvent
|
import org.fairscan.app.ui.screens.about.AboutEvent
|
||||||
import org.fairscan.app.ui.screens.about.AboutScreen
|
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.CameraEvent
|
||||||
import org.fairscan.app.ui.screens.camera.CameraScreen
|
import org.fairscan.app.ui.screens.camera.CameraScreen
|
||||||
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
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.ExportActions
|
||||||
import org.fairscan.app.ui.screens.export.ExportEvent
|
import org.fairscan.app.ui.screens.export.ExportEvent
|
||||||
import org.fairscan.app.ui.screens.export.ExportResult
|
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 -> {
|
is Screen.Main.Document -> {
|
||||||
DocumentScreen (
|
DocumentScreen (
|
||||||
uiState = documentUiState,
|
uiState = documentUiState,
|
||||||
@@ -457,6 +467,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
|
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
|
||||||
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
|
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
|
||||||
|
toEditImageScreen = { pageIndex -> viewModel.navigateTo(Screen.Main.EditImage(pageIndex)) },
|
||||||
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
|
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
|
||||||
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
|
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
|
||||||
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
|
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package org.fairscan.app.ui
|
|||||||
sealed class Screen {
|
sealed class Screen {
|
||||||
sealed class Main : Screen() {
|
sealed class Main : Screen() {
|
||||||
object Camera : Main()
|
object Camera : Main()
|
||||||
|
data class EditImage(val pageIndex: Int) : Main()
|
||||||
data class Document(val initialPage: Int = 0) : Main()
|
data class Document(val initialPage: Int = 0) : Main()
|
||||||
object Export : Main()
|
object Export : Main()
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ sealed class Screen {
|
|||||||
|
|
||||||
data class Navigation(
|
data class Navigation(
|
||||||
val toCameraScreen: () -> Unit,
|
val toCameraScreen: () -> Unit,
|
||||||
|
val toEditImageScreen: (Int) -> Unit,
|
||||||
val toDocumentScreen: () -> Unit,
|
val toDocumentScreen: () -> Unit,
|
||||||
val toExportScreen: () -> Unit,
|
val toExportScreen: () -> Unit,
|
||||||
val toAboutScreen: () -> 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
|
root -> this // Back handled by system
|
||||||
is Screen.Main.Camera -> 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.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.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
|
||||||
is Screen.Overlay -> copy(stack = stack.dropLast(1))
|
is Screen.Overlay -> copy(stack = stack.dropLast(1))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import org.fairscan.app.ui.state.PageThumbnail
|
|||||||
import org.fairscan.imageprocessing.ColorMode
|
import org.fairscan.imageprocessing.ColorMode
|
||||||
|
|
||||||
fun dummyNavigation(): Navigation {
|
fun dummyNavigation(): Navigation {
|
||||||
return Navigation({}, {}, {}, {}, {}, {}, {})
|
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.fairscan.app.ui.components
|
package org.fairscan.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -56,15 +58,18 @@ fun SecondaryActionButton(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
FilledIconButton (
|
FilledIconButton (
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
colors = IconButtonDefaults.outlinedIconButtonColors(
|
colors = IconButtonDefaults.outlinedIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.primary
|
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(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<string name="camera_permission_denied">Camera permission was denied</string>
|
<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="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="cancel">Cancel</string>
|
||||||
|
<string name="confirm">Confirm</string>
|
||||||
<string name="change_directory">Change folder</string>
|
<string name="change_directory">Change folder</string>
|
||||||
<string name="clear_text">Clear text</string>
|
<string name="clear_text">Clear text</string>
|
||||||
<string name="color_mode">Filter</string>
|
<string name="color_mode">Filter</string>
|
||||||
@@ -21,6 +22,8 @@
|
|||||||
<string name="delete_page">Delete page</string>
|
<string name="delete_page">Delete page</string>
|
||||||
<string name="delete_page_warning">Do you want to delete this page?</string>
|
<string name="delete_page_warning">Do you want to delete this page?</string>
|
||||||
<string name="developer">Developer</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="discard_scan">Discard scan</string>
|
||||||
<string name="download_dirname">Downloads</string>
|
<string name="download_dirname">Downloads</string>
|
||||||
<string name="error">Error: %1$s</string>
|
<string name="error">Error: %1$s</string>
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,26 @@ data class Quad(
|
|||||||
Line(bottomLeft, topLeft))
|
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 {
|
fun rotate90(iterations: Int, imageSize: ImageSize): Quad {
|
||||||
val rotatedPoints = listOf(
|
val rotatedPoints = listOf(
|
||||||
rotate90(topLeft, imageSize, iterations),
|
rotate90(topLeft, imageSize, iterations),
|
||||||
|
|||||||
Reference in New Issue
Block a user