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.Screen
|
||||
import org.fairscan.app.ui.components.rememberCameraPermissionState
|
||||
import org.fairscan.app.ui.screens.document.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.edit.EditPageScreen
|
||||
import org.fairscan.app.ui.screens.LibrariesScreen
|
||||
import org.fairscan.app.ui.screens.about.AboutEvent
|
||||
import org.fairscan.app.ui.screens.about.AboutScreen
|
||||
@@ -62,7 +64,6 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
|
||||
import org.fairscan.app.ui.screens.camera.CameraEvent
|
||||
import org.fairscan.app.ui.screens.camera.CameraScreen
|
||||
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
||||
import org.fairscan.app.ui.screens.document.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.export.ExportActions
|
||||
import org.fairscan.app.ui.screens.export.ExportEvent
|
||||
import org.fairscan.app.ui.screens.export.ExportResult
|
||||
@@ -177,6 +178,15 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.Main.EditImage -> {
|
||||
val pageIndex = (currentScreen as Screen.Main.EditImage).pageIndex
|
||||
EditPageScreen(
|
||||
pageId = documentUiState.document.pages[pageIndex].key.pageId,
|
||||
imageRepository = imageRepository,
|
||||
navigation = navigation,
|
||||
onUpdatePageQuad = { id, quad, onComplete -> },
|
||||
)
|
||||
}
|
||||
is Screen.Main.Document -> {
|
||||
DocumentScreen (
|
||||
uiState = documentUiState,
|
||||
@@ -457,6 +467,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
|
||||
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
|
||||
toEditImageScreen = { pageIndex -> viewModel.navigateTo(Screen.Main.EditImage(pageIndex)) },
|
||||
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
|
||||
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
|
||||
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
|
||||
|
||||
@@ -17,6 +17,7 @@ package org.fairscan.app.ui
|
||||
sealed class Screen {
|
||||
sealed class Main : Screen() {
|
||||
object Camera : Main()
|
||||
data class EditImage(val pageIndex: Int) : Main()
|
||||
data class Document(val initialPage: Int = 0) : Main()
|
||||
object Export : Main()
|
||||
}
|
||||
@@ -29,6 +30,7 @@ sealed class Screen {
|
||||
|
||||
data class Navigation(
|
||||
val toCameraScreen: () -> Unit,
|
||||
val toEditImageScreen: (Int) -> Unit,
|
||||
val toDocumentScreen: () -> Unit,
|
||||
val toExportScreen: () -> Unit,
|
||||
val toAboutScreen: () -> Unit,
|
||||
@@ -62,6 +64,7 @@ data class NavigationState private constructor(val stack: List<Screen>, val root
|
||||
root -> this // Back handled by system
|
||||
is Screen.Main.Camera -> this // Back handled by system
|
||||
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document(initialPage = (current as Screen.Main.EditImage).pageIndex)))
|
||||
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Overlay -> copy(stack = stack.dropLast(1))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
fun dummyNavigation(): Navigation {
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {})
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
|
||||
}
|
||||
|
||||
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -56,15 +58,18 @@ fun SecondaryActionButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
FilledIconButton (
|
||||
onClick = onClick,
|
||||
colors = IconButtonDefaults.outlinedIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContainerColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
|
||||
),
|
||||
modifier = modifier.size(40.dp)
|
||||
modifier = modifier.size(40.dp),
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
|
||||
@@ -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_rationale">The app requires camera access to scan documents. Captured images are stored only on this device and will be deleted when you close the current document.</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="confirm">Confirm</string>
|
||||
<string name="change_directory">Change folder</string>
|
||||
<string name="clear_text">Clear text</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
@@ -21,6 +22,8 @@
|
||||
<string name="delete_page">Delete page</string>
|
||||
<string name="delete_page_warning">Do you want to delete this page?</string>
|
||||
<string name="developer">Developer</string>
|
||||
<string name="discard_changes">Discard changes</string>
|
||||
<string name="discard_changes_warning">You have unsaved changes. Do you want to discard them?</string>
|
||||
<string name="discard_scan">Discard scan</string>
|
||||
<string name="download_dirname">Downloads</string>
|
||||
<string name="error">Error: %1$s</string>
|
||||
|
||||
Reference in New Issue
Block a user