diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index f048484..a73e1f5 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -54,6 +54,8 @@ import org.fairscan.app.data.ImageRepository import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.Screen import org.fairscan.app.ui.components.rememberCameraPermissionState +import org.fairscan.app.ui.screens.document.DocumentScreen +import org.fairscan.app.ui.screens.crop.CropScreen 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 @@ -123,6 +124,7 @@ class MainActivity : ComponentActivity() { val importState by cameraViewModel.importState.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle() + val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle() val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle() val cameraPermission = rememberCameraPermissionState() CollectCameraEvents(cameraViewModel, viewModel) @@ -177,6 +179,14 @@ class MainActivity : ComponentActivity() { } ) } + is Screen.Main.EditImage -> { + CropScreen( + pageId = documentUiState.currentPage?.key?.pageId ?: "", + initState = cropInitialState, + navigation = navigation, + onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) }, + ) + } is Screen.Main.Document -> { DocumentScreen ( uiState = documentUiState, @@ -185,6 +195,7 @@ class MainActivity : ComponentActivity() { onDeleteImage = { viewModel.deleteCurrentPage() }, onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) }, onToggleColorMode = { viewModel.toggleCurrentPageColorMode() }, + onCropClick = { viewModel.onClickOnCropButton() }, onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) }, onPageSelected = viewModel::onPageSelected ) @@ -457,6 +468,7 @@ class MainActivity : ComponentActivity() { private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation( toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) }, + toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) }, toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) }, toExportScreen = { viewModel.navigateTo(Screen.Main.Export) }, toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) }, diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index d0f46cd..730e826 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -14,12 +14,16 @@ */ package org.fairscan.app +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -34,14 +38,18 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fairscan.app.data.ImageRepository import org.fairscan.app.domain.CapturedPage +import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.ScanPage import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.Screen import org.fairscan.app.ui.screens.document.CurrentPageUiState import org.fairscan.app.ui.screens.document.DocumentUiState +import org.fairscan.app.ui.screens.crop.CropInitState import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.PageThumbnail import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ImageSize +import org.fairscan.imageprocessing.Quad import kotlin.math.min @OptIn(ExperimentalCoroutinesApi::class) @@ -95,7 +103,8 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() { page?.let { val isLoading = (it.id == loadingId) val bitmap = imageRepository.jpegBytes(it.key())?.toBitmap() - CurrentPageUiState(it.key(), bitmap, it.colorMode, isLoading) + val canBeCropped = page.metadata != null + CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading) } } .flowOn(Dispatchers.IO) @@ -181,6 +190,22 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() { } } + fun setCurrentPageUserQuad(userQuad: Quad) { + viewModelScope.launch { + val currentPage = currentPage() + val totalRotation = currentPage.totalRotation() + val rotateIterations = (4 - totalRotation.degrees / 90) % 4 + val newQuad = userQuad.rotate90(rotateIterations, ImageSize(1, 1)) + _loadingPageId.value = currentPage.id + val pages = withContext(Dispatchers.IO) { + imageRepository.setUserQuad(currentPage.id, newQuad) + imageRepository.pages() + } + _pages.value = pages + _loadingPageId.value = null + } + } + private fun currentPage(): ScanPage { val index = _currentPageIndex.value val pages = _pages.value @@ -212,4 +237,47 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() { _pages.value = pages } } + + private val _cropInitState = MutableStateFlow(CropInitState.Loading) + val cropInitState: StateFlow = _cropInitState + + private var cropInitialStateJob: Job? = null + fun onClickOnCropButton() { + cropInitialStateJob?.cancel() + cropInitialStateJob = viewModelScope.launch { + _cropInitState.value = CropInitState.Loading + + val page = currentPage() + + val metadata = page.metadata + val rotation = page.totalRotation() + + val bitmap = withContext(Dispatchers.IO) { + val source = imageRepository.source(page.id) + val bytes = source?.bytes ?: return@withContext null + + val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + if (original != null && rotation != Rotation.R0) { + val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) } + Bitmap.createBitmap( + original, 0, 0, original.width, original.height, matrix, true + ) + } else { + original + } + } + + val quad = metadata?.normalizedQuad?.rotate90( + rotation.degrees / 90, + ImageSize(1, 1) + ) + + _cropInitState.value = if (bitmap == null || quad == null) + CropInitState.Error + else + CropInitState.Ready(page.id, bitmap, quad) + navigateTo(Screen.Main.EditImage) + } + + } } diff --git a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt index 7e92075..9314330 100644 --- a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt +++ b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt @@ -40,6 +40,8 @@ data class PageV2( val baseRotationDegrees: Int = 0, val manualRotationDegrees: Int = 0, val quad: NormalizedQuad? = null, + val quadVersion: Int = 0, + val userQuad: NormalizedQuad? = null, val isColored: Boolean? = null, val colorMode: ColorMode? = null, ) diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 7089cd1..e388b39 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -27,7 +27,6 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey @@ -91,7 +90,7 @@ class ImageRepository( return when { metadataPages != null -> metadataPages - .filter { processedImageFileName(it.id, it.colorMode) in filesOnDisk } + .filter { processedImageFileName(it.id, it.colorMode, it.quadVersion) in filesOnDisk } .toMutableList() else -> filesOnDisk @@ -135,7 +134,7 @@ class ImageRepository( pages.pages().mapNotNull { runCatching { val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees) - ScanPage(it.id, manualRotation, it.colorMode, it.toMetadata()) + ScanPage(it.id, manualRotation, it.colorMode, it.quadVersion, it.toMetadata()) }.getOrNull() } } @@ -143,7 +142,7 @@ class ImageRepository( suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) = mutex.withLock { val id = "${System.currentTimeMillis()}" - val key = PageViewKey(id, Rotation.R0, colorMode) + val key = PageViewKey(id, Rotation.R0, colorMode, 0) processedImageFile(key).writeBytes(processed.bytes) sourceFile(id).writeBytes(source.bytes) pages.addOrReplace( @@ -162,18 +161,64 @@ class ImageRepository( } suspend fun setColorMode(id: String, colorMode: ColorMode) { - val key = PageViewKey(id, Rotation.R0, colorMode) - val processedFile = processedImageFile(key) - val metadata = mutex.withLock { pages.get(id)?.toMetadata() } + updatePage(id) { page, metadata -> + PageUpdate( + updatedPage = page.copy(colorMode = colorMode), + normalizedQuad = metadata.normalizedQuad, + colorMode = colorMode, + ) + } + } + + suspend fun setUserQuad(id: String, newQuad: Quad) { + updatePage(id) { page, metadata -> + PageUpdate( + updatedPage = page.copy( + quadVersion = page.quadVersion + 1, + userQuad = newQuad.toSerializable(), + ), + normalizedQuad = newQuad, + colorMode = page.colorMode ?: metadata.autoColorMode, + ) + } + } + + private data class PageUpdate( + val updatedPage: PageV2, + val normalizedQuad: Quad, + val colorMode: ColorMode, + ) + + private suspend fun updatePage( + id: String, + buildUpdate: (PageV2, PageMetadata) -> PageUpdate + ) { + val page = mutex.withLock { pages.get(id) } + val metadata = page?.toMetadata() ?: return val sourceFile = sourceFile(id) - if (metadata == null || !sourceFile.exists()) + if (!sourceFile.exists()) return + val update = buildUpdate(page, metadata) + val key = PageViewKey( + pageId = id, + rotation = Rotation.R0, + colorMode = update.colorMode, + quadVersion = update.updatedPage.quadVersion + ) + + val processedFile = processedImageFile(key) val job = processingJobs.computeIfAbsent(key) { scope.async(Dispatchers.IO) { if (!processedFile.exists()) { val sourceJpeg = Jpeg(sourceFile.readBytes()) - val processedJpeg = transformations.process(sourceJpeg, metadata, colorMode) + val processedJpeg = + transformations.process( + sourceJpeg, + normalizedQuad = update.normalizedQuad, + baseRotation = metadata.baseRotation, + colorMode = update.colorMode + ) processedFile.writeBytes(processedJpeg.bytes) } } @@ -185,7 +230,7 @@ class ImageRepository( } mutex.withLock { - pages.update(id) { it.copy(colorMode = colorMode) } + pages.update(id) { update.updatedPage } saveMetadata() } } @@ -248,14 +293,18 @@ class ImageRepository( // --- Other operations --- - private fun processedImageFileName(id: String, colorMode: ColorMode?) : String = - if (colorMode == null) - "${id}.jpg" - else - "${id}.${colorMode.name.lowercase()}.jpg" + private fun processedImageFileName(id: String, colorMode: ColorMode?, quadVersion: Int) : String { + val sb = StringBuilder(id) + if (colorMode != null) + sb.append(".").append(colorMode.name.lowercase()) + if (quadVersion > 0) + sb.append(".q").append(quadVersion) + sb.append(".jpg") + return sb.toString() + } private fun processedImageFile(key: PageViewKey) : File = - File(processedDir, processedImageFileName(key.pageId, key.colorMode)) + File(processedDir, processedImageFileName(key.pageId, key.colorMode, key.quadVersion)) private fun sourceFile(id: String): File = File(sourceDir, "$id.jpg") @@ -352,7 +401,7 @@ fun NormalizedQuad.toQuad(): Quad = fun PageV2.toMetadata(): PageMetadata? { if (quad == null || isColored == null) return null return PageMetadata( - quad.toQuad(), + (userQuad ?: quad).toQuad(), Rotation.fromDegrees(baseRotationDegrees), if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE ) diff --git a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt index 3d55c52..ef42a8b 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -15,8 +15,9 @@ package org.fairscan.app.data import org.fairscan.app.domain.Jpeg -import org.fairscan.app.domain.PageMetadata +import org.fairscan.app.domain.Rotation import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.Quad interface ImageTransformations { @@ -24,6 +25,11 @@ interface ImageTransformations { fun resizeToThumbnail(input: Jpeg): Jpeg - fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg + fun process( + source: Jpeg, + normalizedQuad: Quad, + baseRotation: Rotation, + colorMode: ColorMode + ): Jpeg } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index 9d53ab3..8d6d816 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -48,11 +48,11 @@ suspend fun jpegsForExport( JpegProvider { val source = imageRepository.source(page.id) val metadata = page.metadata - val manualRotation = page.manualRotation val colorMode = page.colorMode if (source != null && metadata != null && colorMode != null) { - val rotation = metadata.baseRotation.add(manualRotation) - processedImage(source, metadata, rotation, colorMode, exportQuality) + val rotation = page.totalRotation() + val normalizedQuad = metadata.normalizedQuad + processedImage(source, normalizedQuad, rotation, colorMode, exportQuality) } else jpeg(page, imageRepository) diff --git a/app/src/main/java/org/fairscan/app/domain/Page.kt b/app/src/main/java/org/fairscan/app/domain/Page.kt index 262a0ed..464853a 100644 --- a/app/src/main/java/org/fairscan/app/domain/Page.kt +++ b/app/src/main/java/org/fairscan/app/domain/Page.kt @@ -27,19 +27,19 @@ data class ScanPage( val id: String, val manualRotation: Rotation, val colorMode: ColorMode?, + val quadVersion: Int, val metadata: PageMetadata?, ) { - fun key(): PageViewKey = PageViewKey(id, manualRotation, colorMode) + fun key() = PageViewKey(id, manualRotation, colorMode, quadVersion) + fun totalRotation() = manualRotation.add(metadata?.baseRotation ?: Rotation.R0) } data class PageViewKey( val pageId: String, val rotation: Rotation, val colorMode: ColorMode?, -) { - val saveKey: String get() = "$pageId-${rotation.degrees}-$colorMode" -} - + val quadVersion: Int, +) enum class Rotation(val degrees: Int) { R0(0), R90(90), diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index bafdb43..85cfcd7 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -27,9 +27,13 @@ import org.fairscan.app.domain.Rotation import org.fairscan.app.ui.screens.settings.DefaultColorMode import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.Mask +import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.autoColorMode +import org.fairscan.imageprocessing.createQuad import org.fairscan.imageprocessing.extractDocument +import org.fairscan.imageprocessing.resizeForMaxPixels +import org.fairscan.imageprocessing.rotate import org.fairscan.imageprocessing.scaledTo import org.opencv.android.Utils import org.opencv.core.Mat @@ -73,14 +77,19 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { } } - override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg { - return processedImage(source, metadata, metadata.baseRotation, colorMode, ExportQuality.BALANCED) + override fun process( + source: Jpeg, + normalizedQuad: Quad, + baseRotation: Rotation, + colorMode: ColorMode + ): Jpeg { + return processedImage(source, normalizedQuad, baseRotation, colorMode, ExportQuality.BALANCED) } } fun processedImage( source: Jpeg, - metadata: PageMetadata, + normalizedQuad: Quad, rotation: Rotation, colorMode: ColorMode, exportQuality: ExportQuality, @@ -90,7 +99,7 @@ fun processedImage( var page: Mat? = null try { sourceMat = source.toMat() - val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) + val quad = normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels) return Jpeg.fromMat(page, exportQuality.jpegQuality) } finally { @@ -101,28 +110,43 @@ fun processedImage( fun extractDocumentFromBitmap( source: Bitmap, - quadInMask: Quad, + quadInMask: Quad?, rotationDegrees: Int, - mask: Mask, + mask: Mask?, viewModelScope: CoroutineScope, defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO ): CapturedPage { val exportQuality = ExportQuality.BALANCED - val quad = quadInMask.scaledTo(mask.width, mask.height, source.width, source.height) + var colorMode = ColorMode.COLOR + var autoColorMode = colorMode + var normalizedQuad = createQuad(listOf( + Point(0.0, 0.0), Point(0.0, 1.0), Point(1.0, 1.0), Point(1.0, 0.0)) + ) + var page: Mat val rgba = Mat() Utils.bitmapToMat(source, rgba) val bgr = Mat() Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) rgba.release() - val autoColorMode = autoColorMode(bgr, mask, quad) - val colorMode = defaultColorMode.colorMode ?: autoColorMode - val page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels) + + if (mask == null || quadInMask == null) { + // No document detected + val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels.toDouble()) + page = rotate(resized, rotationDegrees) + resized.release() + } else { + val quad = quadInMask.scaledTo(mask.width, mask.height, source.width, source.height) + normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) + autoColorMode = autoColorMode(bgr, mask, quad) + colorMode = defaultColorMode.colorMode ?: autoColorMode + page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels) + } + val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality) bgr.release() page.release() - val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) val baseRotation = Rotation.fromDegrees(rotationDegrees) val metadata = PageMetadata(normalizedQuad, baseRotation, autoColorMode) val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) { diff --git a/app/src/main/java/org/fairscan/app/ui/Navigation.kt b/app/src/main/java/org/fairscan/app/ui/Navigation.kt index a6d7741..79f7732 100644 --- a/app/src/main/java/org/fairscan/app/ui/Navigation.kt +++ b/app/src/main/java/org/fairscan/app/ui/Navigation.kt @@ -17,6 +17,7 @@ package org.fairscan.app.ui sealed class Screen { sealed class Main : Screen() { object Camera : Main() + object EditImage : 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: () -> Unit, val toDocumentScreen: () -> Unit, val toExportScreen: () -> Unit, val toAboutScreen: () -> Unit, @@ -62,6 +64,7 @@ data class NavigationState private constructor(val stack: List, val root root -> this // Back handled by system is Screen.Main.Camera -> this // Back handled by system is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera)) + is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document())) is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera)) is Screen.Overlay -> copy(stack = stack.dropLast(1)) } diff --git a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt index 048abc0..2fa0c86 100644 --- a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt @@ -25,12 +25,12 @@ import org.fairscan.app.ui.state.PageThumbnail import org.fairscan.imageprocessing.ColorMode fun dummyNavigation(): Navigation { - return Navigation({}, {}, {}, {}, {}, {}, {}) + return Navigation({}, {}, {}, {}, {}, {}, {}, {}) } fun fakeDocument(pageIds: ImmutableList, context: Context): DocumentUiModel { val pageKeys = pageIds.map { - PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR), fakeImage(it, context)) + PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR, 0), fakeImage(it, context)) }.toImmutableList() return DocumentUiModel(pageKeys) } diff --git a/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt b/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt index 86c15d5..e8da2fc 100644 --- a/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt +++ b/app/src/main/java/org/fairscan/app/ui/components/Buttons.kt @@ -14,6 +14,7 @@ */ package org.fairscan.app.ui.components +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -56,15 +58,18 @@ fun SecondaryActionButton( icon: ImageVector, contentDescription: String, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { FilledIconButton ( onClick = onClick, colors = IconButtonDefaults.outlinedIconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f), - contentColor = MaterialTheme.colorScheme.primary + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.primary, + disabledContainerColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray ), - modifier = modifier.size(40.dp) + modifier = modifier.size(40.dp), + enabled = enabled ) { Icon( imageVector = icon, diff --git a/app/src/main/java/org/fairscan/app/ui/components/Scaffold.kt b/app/src/main/java/org/fairscan/app/ui/components/Scaffold.kt index d5b2271..142dfa4 100644 --- a/app/src/main/java/org/fairscan/app/ui/components/Scaffold.kt +++ b/app/src/main/java/org/fairscan/app/ui/components/Scaffold.kt @@ -54,15 +54,20 @@ import org.fairscan.app.ui.Navigation @Composable fun MyScaffold( navigation: Navigation, - pageListState: CommonPageListState, + pageListState: CommonPageListState? = null, bottomBar: @Composable () -> Unit, - onBack: (() -> Unit)? = null, content: @Composable (Modifier) -> Unit, ) { Box { if (!isLandscape(LocalConfiguration.current)) { Scaffold( - bottomBar = { DocumentBar(pageListState, bottomBar, Modifier) } + bottomBar = { + if (pageListState == null) { + bottomBar + } else { + DocumentBar(pageListState, bottomBar, Modifier) + } + } ) { innerPadding -> content(Modifier.padding(innerPadding).fillMaxSize()) } @@ -72,18 +77,20 @@ fun MyScaffold( modifier = Modifier.padding(innerPadding).fillMaxSize() ) { content(Modifier.weight(2f)) - DocumentBar(pageListState, bottomBar, Modifier.weight(1f)) + if (pageListState == null) { + bottomBar + } else { + DocumentBar(pageListState, bottomBar, Modifier.weight(1f)) + } } } } - if (onBack != null) { - BackButton( - onBack, - modifier = Modifier - .align(Alignment.TopStart) - .windowInsetsPadding(WindowInsets.safeDrawing) - ) - } + BackButton( + navigation.back, + modifier = Modifier + .align(Alignment.TopStart) + .windowInsetsPadding(WindowInsets.safeDrawing) + ) AppOverflowMenu( navigation, modifier = Modifier diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index 342bea3..af5d984 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -16,7 +16,6 @@ package org.fairscan.app.ui.screens.camera import android.content.res.Configuration import android.util.Log -import androidx.activity.compose.BackHandler import androidx.camera.core.ImageProxy import androidx.camera.view.PreviewView import androidx.compose.animation.core.animateFloat @@ -293,7 +292,6 @@ private fun CameraScreenScaffold( MyScaffold( navigation = navigation, pageListState = pageListState, - onBack = navigation.back, bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed, onImportClicked) } ) { modifier -> if (cameraUiState.importState is ImportState.Selecting) { @@ -527,7 +525,7 @@ private fun CameraPreviewWithOverlay( .background(Color.Black.copy(alpha = 0.6f)) ) } - if (cameraUiState.showDetectionError) { + if (cameraUiState.showCaptureError) { Box( modifier = Modifier .align(Alignment.Center) @@ -535,7 +533,7 @@ private fun CameraPreviewWithOverlay( .padding(16.dp) ) { Text( - text = stringResource(R.string.error_no_document), + text = stringResource(R.string.error_occurred), color = Color.White, fontSize = 16.sp ) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraUiState.kt index 14beb6d..adbf0bf 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraUiState.kt @@ -38,7 +38,7 @@ data class CameraUiState( val liveAnalysisState: LiveAnalysisState, val captureState: CaptureState, val importState: ImportState, - val showDetectionError: Boolean, + val showCaptureError: Boolean, val isLandscape: Boolean, val isDebugMode: Boolean, val isTorchEnabled: Boolean, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt index 3d46eec..6ebfda0 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt @@ -154,19 +154,14 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { private suspend fun processCapturedImage( source: Bitmap, rotationDegrees: Int, - ): CapturedPage? = withContext(Dispatchers.IO) { - var result: CapturedPage? = null + ): CapturedPage = withContext(Dispatchers.IO) { val segmentation = imageSegmentationService.runSegmentationAndReturn(source) - if (segmentation != null) { - val mask = segmentation.segmentation - val originalSize = ImageSize(source.width, source.height) - val quad = detectDocumentQuad(mask, originalSize, isLiveAnalysis = false) - if (quad != null) { - val defaultColorMode = settingsRepository.defaultColorMode.first() - result = extractDocumentFromBitmap( - source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode) - } - } + val mask = segmentation?.segmentation + val originalSize = ImageSize(source.width, source.height) + val quad = mask?.let { detectDocumentQuad(mask, originalSize, isLiveAnalysis = false) } + val defaultColorMode = settingsRepository.defaultColorMode.first() + val result = extractDocumentFromBitmap( + source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode) return@withContext result } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/crop/CropScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/crop/CropScreen.kt new file mode 100644 index 0000000..1818abb --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/crop/CropScreen.kt @@ -0,0 +1,324 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory +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.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.fairscan.app.R +import org.fairscan.app.ui.Navigation +import org.fairscan.app.ui.components.MainActionButton +import org.fairscan.app.ui.components.MyScaffold +import org.fairscan.app.ui.components.isLandscape +import org.fairscan.app.ui.dummyNavigation +import org.fairscan.app.ui.theme.FairScanTheme +import org.fairscan.imageprocessing.Point +import org.fairscan.imageprocessing.Quad + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CropScreen( + pageId: String, + initState: CropInitState, + navigation: Navigation, + onUpdatePageQuad: (Quad) -> Unit, +) { + val state = remember(pageId) { CropScreenState() } + val quadHandler = remember { QuadEditingHandler() } + + if (initState is CropInitState.Ready && initState.pageId == pageId && state.bitmap == null) { + state.bitmap = initState.bitmap + state.setInitialQuad(initState.quad) + } + + BackHandler { navigation.back() } + + val isLandscape = isLandscape(LocalConfiguration.current) + + MyScaffold( + navigation = navigation, + bottomBar = {}, + ) { modifier -> + + Box(modifier = modifier.fillMaxSize()) { + 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) + } + } + + DragMagnifyingGlass(state) + + ActionButtons( + modifier = Modifier + .align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter) + .padding(16.dp) + .windowInsetsPadding(WindowInsets.safeDrawing), + onConfirm = { + state.editableQuad?.let { onUpdatePageQuad(it) } + navigation.back() + } + ) + } + } +} + +@Composable +private fun ActionButtons( + modifier: Modifier, + onConfirm: () -> Unit +) { + MainActionButton( + onClick = onConfirm, + text = stringResource(R.string.apply), + icon = Icons.Filled.Check, + iconDescription = stringResource(R.string.apply), + modifier = modifier + ) +} + + +@Composable +private fun DragQuadOverlay( + state: CropScreenState, + quadHandler: QuadEditingHandler, + bmp: 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) { + CropScreenState.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: CropScreenState) { + // showLoupe becomes true immediately on touch-down and stays true for + // one additional second after the finger is lifted. + val showLoupe = remember { mutableStateOf(false) } + // Remember the last valid focus position so the loupe keeps rendering + // correctly during the 1-second fade-out (when dragged indices are reset). + val lastKnownFocusPosition = remember { mutableStateOf(null) } + + LaunchedEffect(state.isTouching) { + if (state.isTouching) { + showLoupe.value = true + } else { + delay(1_000) + showLoupe.value = false + } + } + + if (!showLoupe.value || state.dragPosition == null || state.containerSize == null) return + + val bmp = state.bitmap ?: return + val containerSize = state.containerSize!! + val displaySize = QuadCoordinateUtils.calculateDisplaySize( + bmp.width, bmp.height, containerSize + ) + val quad = state.editableQuad + + // Resolve which corner index to focus on. + // Priority: active drag > pre-drag touch-down > nothing (fade-out phase). + val activeCornerIndex = state.draggedCornerIndex.takeIf { it >= 0 } + ?: state.touchDownCornerIndex.takeIf { it >= 0 } + + val focusPosition = if (quad != null) { + when { + activeCornerIndex != null -> { + val corner = when (activeCornerIndex) { + 0 -> quad.topLeft + 1 -> quad.topRight + 2 -> quad.bottomRight + 3 -> quad.bottomLeft + else -> null + } + corner?.let { + QuadCoordinateUtils.normalizedToScreen(it, containerSize, displaySize) + } + } + else -> null + } + } else null + + // Keep the last known focus position so it's still valid after endDrag() resets the indices. + if (focusPosition != null) lastKnownFocusPosition.value = focusPosition + // On the very first touch the drag indices are not set yet and lastKnownFocusPosition + // has never been populated, so fall back to dragPosition (the finger is on the handle). + val effectiveFocusPosition = focusPosition ?: lastKnownFocusPosition.value ?: state.dragPosition ?: return + + MagnifyingGlass( + bitmap = bmp, + fingerPosition = state.dragPosition!!, + focusPosition = effectiveFocusPosition, + containerSize = containerSize, + displaySize = displaySize, + quad = state.editableQuad, + ) +} + +@Composable +@Preview(showSystemUi = true) +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, showSystemUi = true) +@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320) +@Preview(name = "RTL", locale = "ar", showSystemUi = true) +fun EditPageScreenPreview() { + FairScanTheme { + val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input -> + BitmapFactory.decodeStream(input) + } + val quad = Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9)) + CropScreen( + pageId = "123", + initState = CropInitState.Ready("123",dummyImage, quad), + navigation = dummyNavigation(), + onUpdatePageQuad = { _ -> }, + ) + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/crop/CropScreenState.kt b/app/src/main/java/org/fairscan/app/ui/screens/crop/CropScreenState.kt new file mode 100644 index 0000000..7ef2d6b --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/crop/CropScreenState.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import android.graphics.Bitmap +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 + +sealed interface CropInitState { + object Loading : CropInitState + object Error : CropInitState + data class Ready( + val pageId: String, + val bitmap: Bitmap, + val quad: Quad + ) : CropInitState +} + +class CropScreenState { + companion object { + val LIFT_WIGGLE_MAX_DISTANCE = 8.dp + const val LIFT_WIGGLE_WINDOW_MS = 70L + } + + var bitmap by mutableStateOf(null) + var containerSize by mutableStateOf(null) + var editableQuad by mutableStateOf(null) + var draggedCornerIndex by mutableIntStateOf(-1) + var dragPosition by mutableStateOf(null) + /** True from the moment the finger touches a drag handle until it is lifted. */ + var isTouching by mutableStateOf(false) + /** + * Corner / edge index detected at the raw touch-down (before touch-slop). + * Carried into [startCornerDrag] so that the slop-adjusted + * position in onDragStart cannot miss the handle. + */ + var touchDownCornerIndex by mutableIntStateOf(-1) + + private var quadBeforeDrag: Quad? = null + private var quadBeforeLastDragStep: Quad? = null + private var lastDragStepDistancePx: Float = Float.MAX_VALUE + private var lastDragStepAtMs: Long = 0L + private var initialQuad: Quad? = null + + fun updateQuad(newQuad: Quad) { + editableQuad = newQuad + } + + fun startCornerDrag(cornerIndex: Int) { + quadBeforeDrag = editableQuad + draggedCornerIndex = cornerIndex + clearLastDragStep() + } + + fun recordDragStep(previousQuad: Quad, dragAmount: Offset, eventTimeMs: Long = System.currentTimeMillis()) { + quadBeforeLastDragStep = previousQuad + lastDragStepDistancePx = dragAmount.getDistance() + lastDragStepAtMs = eventTimeMs + } + + fun rollbackLastDragStepIfLikelyLiftWiggle( + maxDistancePx: Float, + nowMs: Long = System.currentTimeMillis() + ) { + if (quadBeforeLastDragStep == null) return + val isRecent = nowMs - lastDragStepAtMs <= LIFT_WIGGLE_WINDOW_MS + val isSmall = lastDragStepDistancePx <= maxDistancePx + if (isRecent && isSmall) { + editableQuad = quadBeforeLastDragStep + } + } + + fun endDrag() { + quadBeforeDrag = null + clearLastDragStep() + draggedCornerIndex = -1 + // dragPosition is intentionally kept so the loupe can still render + // during its 1-second fade-out after the finger is lifted. + } + + private fun clearLastDragStep() { + quadBeforeLastDragStep = null + lastDragStepDistancePx = Float.MAX_VALUE + lastDragStepAtMs = 0L + } + + /** + * Called as soon as the finger touches a drag handle (before touch-slop), + * so the loupe is shown immediately. + * [cornerIndex] is the handle index found at the exact + * touch position; it is stored so that drag start handling can use it even + * if the slop-adjusted position drifts outside the hit-test radius. + */ + fun onTouchDown(position: Offset, cornerIndex: Int = -1) { + isTouching = true + dragPosition = position + touchDownCornerIndex = cornerIndex + } + + /** Called when the finger is lifted; triggers the loupe fade-out delay. */ + fun onTouchUp() { + isTouching = false + touchDownCornerIndex = -1 + } + + fun isDragging(): Boolean = draggedCornerIndex >= 0 + + fun setInitialQuad(quad: Quad) { + initialQuad = quad + editableQuad = quad + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/crop/MagnifyingGlass.kt b/app/src/main/java/org/fairscan/app/ui/screens/crop/MagnifyingGlass.kt new file mode 100644 index 0000000..63e4b5b --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/crop/MagnifyingGlass.kt @@ -0,0 +1,269 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import org.fairscan.imageprocessing.Quad +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt + +private val LOUPE_BORDER_WIDTH = 3.dp +private const val ZOOM_FACTOR = 3f + +/** + * Layout parameters for the magnifying-glass loupe. + * + * Use `LoupeLayoutConfig` in Compose UI code and [toPx] to convert to + * `LoupeLayoutConfig` for pixel-level calculations. + */ +data class LoupeLayoutConfig( + /** Radius of the loupe circle */ + val loupeRadius: T, + /** Gap between the finger and the nearest edge of the loupe */ + val verticalOffset: T, + /** Minimum space between the loupe and the screen edge */ + val screenMargin: T, +) { + companion object { + val Default = LoupeLayoutConfig( + loupeRadius = 60.dp, + verticalOffset = 40.dp, + screenMargin = 8.dp, + ) + } +} + +/** Convert a dp-valued config to pixels using the current [LocalDensity]. */ +@Composable +internal fun LoupeLayoutConfig.toPx(): LoupeLayoutConfig { + val density = LocalDensity.current + return with(density) { + LoupeLayoutConfig( + loupeRadius = loupeRadius.toPx(), + verticalOffset = verticalOffset.toPx(), + screenMargin = screenMargin.toPx(), + ) + } +} + +/** + * Displays a magnifying glass / loupe showing a zoomed-in patch of [bitmap] + * centred around [focusPosition]. + * + * Positioning rules: + * 1. By default, the loupe is placed **above** the finger. + * 2. If there is not enough room above, it moves to the **left** of the finger. + * 3. If there is not enough room on the left either, it moves to the **right**. + * + * @param bitmap The full source bitmap (original image). + * @param fingerPosition Current finger position in screen (container) coordinates, used for loupe placement. + * @param focusPosition The exact point to zoom into (e.g. corner or edge midpoint) in screen coordinates. + * @param containerSize Size of the full-screen container. + * @param displaySize Size of the image as rendered (letterboxed inside the container). + */ +@Composable +fun MagnifyingGlass( + bitmap: Bitmap, + fingerPosition: Offset, + focusPosition: Offset, + containerSize: IntSize, + displaySize: IntSize, + quad: Quad? = null, + configDp: LoupeLayoutConfig = LoupeLayoutConfig.Default, +) { + val density = LocalDensity.current + val configPx = configDp.toPx() + val borderWidth = with(density) { LOUPE_BORDER_WIDTH.toPx() } + + // compute loupe centre position + val loupeCenter = computeLoupeCenter( + dragPosition = fingerPosition, + configPx = configPx, + containerWidth = containerSize.width.toFloat(), + ) + + // compute the bitmap region to sample + val imageOffset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize) + + // Focus position mapped to bitmap pixel coordinates + val bitmapX = ((focusPosition.x - imageOffset.width) / displaySize.width * bitmap.width) + .coerceIn(0f, (bitmap.width - 1).toFloat()) + val bitmapY = ((focusPosition.y - imageOffset.height) / displaySize.height * bitmap.height) + .coerceIn(0f, (bitmap.height - 1).toFloat()) + + // How many bitmap pixels the loupe shows in each direction + val bitmapRegionHalf = (bitmap.width / displaySize.width.toFloat()) * configPx.loupeRadius / ZOOM_FACTOR + + val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() } + + val borderColor = MaterialTheme.colorScheme.primary + val quadLineColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + val backgroundColor = MaterialTheme.colorScheme.background + + Canvas( + modifier = Modifier + .size(configDp.loupeRadius * 2) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative( + IntOffset( + (loupeCenter.x - configPx.loupeRadius).roundToInt(), + (loupeCenter.y - configPx.loupeRadius).roundToInt() + ) + ) + } + } + ) { + val loupeDiameter = configPx.loupeRadius * 2 + val circlePath = Path().apply { + addOval( + androidx.compose.ui.geometry.Rect(0f, 0f, loupeDiameter, loupeDiameter) + ) + } + + clipPath(circlePath) { + // Fill background so areas outside the image are opaque + drawRect(color = backgroundColor) + + // Source rect in bitmap coordinates + val srcLeft = (bitmapX - bitmapRegionHalf).toInt().coerceAtLeast(0) + val srcTop = (bitmapY - bitmapRegionHalf).toInt().coerceAtLeast(0) + val srcRight = (bitmapX + bitmapRegionHalf).toInt().coerceAtMost(bitmap.width) + val srcBottom = (bitmapY + bitmapRegionHalf).toInt().coerceAtMost(bitmap.height) + + if (srcRight > srcLeft && srcBottom > srcTop) { + + // Destination offset – compensate when the source rect was clamped + val dstOffsetX = ((srcLeft - (bitmapX - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter) + val dstOffsetY = ((srcTop - (bitmapY - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter) + + val dstWidth = ((srcRight - srcLeft) / (2 * bitmapRegionHalf) * loupeDiameter).toInt() + val dstHeight = ((srcBottom - srcTop) / (2 * bitmapRegionHalf) * loupeDiameter).toInt() + + drawImage( + image = imageBitmap, + srcOffset = IntOffset(srcLeft, srcTop), + srcSize = IntSize(srcRight - srcLeft, srcBottom - srcTop), + dstOffset = IntOffset(dstOffsetX.toInt(), dstOffsetY.toInt()), + dstSize = IntSize(dstWidth, dstHeight), + ) + } + + // Draw quad overlay and edges inside the loupe + // Convert each normalized quad corner to bitmap-pixel coords, + // then to loupe-local coords using the same mapping as the bitmap sampling. + if (quad != null) { + val bitmapOriginX = bitmapX - bitmapRegionHalf + val bitmapOriginY = bitmapY - bitmapRegionHalf + val scale = loupeDiameter / (2 * bitmapRegionHalf) + + fun normalizedToLoupe(nx: Double, ny: Double): Offset { + val bx = (nx * bitmap.width).toFloat() + val by = (ny * bitmap.height).toFloat() + return Offset( + (bx - bitmapOriginX) * scale, + (by - bitmapOriginY) * scale + ) + } + + val loupeCorners = listOf( + normalizedToLoupe(quad.topLeft.x, quad.topLeft.y), + normalizedToLoupe(quad.topRight.x, quad.topRight.y), + normalizedToLoupe(quad.bottomRight.x, quad.bottomRight.y), + normalizedToLoupe(quad.bottomLeft.x, quad.bottomLeft.y), + ) + + // Draw quad edge lines on top + for (i in 0 until 4) { + drawLine( + color = quadLineColor, + start = loupeCorners[i], + end = loupeCorners[(i + 1) % 4], + strokeWidth = 3f + ) + } + } + } + + // Border + drawCircle( + color = borderColor, + radius = configPx.loupeRadius - borderWidth / 2, + center = Offset(configPx.loupeRadius, configPx.loupeRadius), + style = Stroke(width = borderWidth) + ) + } +} + +/** + * Decides where the loupe centre should be. + * + * Priority: + * 1. Above the finger (centred horizontally, clamped to screen edges). + * 2. If no vertical room -> to the left. + * 3. If no room on the left -> to the right. + */ +internal fun computeLoupeCenter( + dragPosition: Offset, + configPx: LoupeLayoutConfig, + containerWidth: Float, +): Offset { + val loupeRadius = configPx.loupeRadius + val verticalOffset = configPx.verticalOffset + val screenMargin = configPx.screenMargin + + // Try above + val aboveCenterY = dragPosition.y - verticalOffset - loupeRadius + if (aboveCenterY - loupeRadius >= screenMargin) { + // Enough room above -> place centred horizontally on the finger, clamped to screen edges + val cx = dragPosition.x.coerceIn(screenMargin + loupeRadius, containerWidth - screenMargin - loupeRadius) + return Offset(cx, aboveCenterY) + } + + // Not enough room above -> try left + val leftCenterX = dragPosition.x - verticalOffset - loupeRadius + if (leftCenterX - loupeRadius >= screenMargin) { + val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE) + return Offset(leftCenterX, cy) + } + + // Not enough room on the left -> place right + val rightCenterX = dragPosition.x + verticalOffset + loupeRadius + val cx = rightCenterX.coerceAtMost(containerWidth - screenMargin - loupeRadius) + val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE) + return Offset(cx, cy) +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadCoordinateUtils.kt b/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadCoordinateUtils.kt new file mode 100644 index 0000000..4b1d850 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadCoordinateUtils.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.fairscan.imageprocessing.Point + +object QuadCoordinateUtils { + + fun calculateDisplaySize( + bitmapWidth: Int, + bitmapHeight: Int, + containerSize: IntSize + ): IntSize { + val imageAspectRatio = bitmapWidth.toFloat() / bitmapHeight.toFloat() + val containerAspectRatio = containerSize.width / containerSize.height.toFloat() + + return if (imageAspectRatio > containerAspectRatio) { + IntSize(containerSize.width, (containerSize.width / imageAspectRatio).toInt()) + } else { + IntSize((containerSize.height * imageAspectRatio).toInt(), containerSize.height) + } + } + + fun normalizedToScreen(point: Point, containerSize: IntSize, displaySize: IntSize): Offset { + val offsetX = (containerSize.width - displaySize.width) / 2 + val offsetY = (containerSize.height - displaySize.height) / 2 + return Offset( + x = (point.x * displaySize.width).toFloat() + offsetX, + y = (point.y * displaySize.height).toFloat() + offsetY + ) + } + + fun screenDeltaToNormalized(delta: Offset, displaySize: IntSize): Offset { + return Offset( + x = delta.x / displaySize.width, + y = delta.y / displaySize.height + ) + } + + fun getImageOffset(containerSize: IntSize, displaySize: IntSize): IntSize { + return IntSize( + (containerSize.width - displaySize.width) / 2, + (containerSize.height - displaySize.height) / 2 + ) + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadEditingHandler.kt b/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadEditingHandler.kt new file mode 100644 index 0000000..7d92fbd --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadEditingHandler.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.fairscan.imageprocessing.Point +import org.fairscan.imageprocessing.Quad + +class QuadEditingHandler { + + companion object { + const val CORNER_RADIUS = 40f + const val CORNER_TOUCH_RADIUS = 90f + } + + fun findTouchedCorner( + touchPos: Offset, + quad: Quad, + containerSize: IntSize, + displaySize: IntSize + ): Int { + return findTouchedCornerCandidates(touchPos, quad, containerSize, displaySize) + .firstOrNull() ?: -1 + } + + fun findTouchedCornerCandidates( + touchPos: Offset, + quad: Quad, + containerSize: IntSize, + displaySize: IntSize + ): List { + val corners = getCornerPositions(quad, containerSize, displaySize) + return corners + .mapIndexed { index, corner -> index to (touchPos - corner).getDistance() } + .filter { (_, distance) -> distance < CORNER_TOUCH_RADIUS } + .sortedBy { (_, distance) -> distance } + .map { (index, _) -> index } + } + + fun updateQuadCorner(quad: Quad, cornerIndex: Int, delta: Offset): Quad { + val normalizedDelta = Point(delta.x.toDouble(), delta.y.toDouble()) + val candidate = when (cornerIndex) { + 0 -> quad.copy(topLeft = clampPoint(quad.topLeft + normalizedDelta)) + 1 -> quad.copy(topRight = clampPoint(quad.topRight + normalizedDelta)) + 2 -> quad.copy(bottomRight = clampPoint(quad.bottomRight + normalizedDelta)) + 3 -> quad.copy(bottomLeft = clampPoint(quad.bottomLeft + normalizedDelta)) + else -> quad + } + return if (candidate.isConvex()) candidate else quad + } + + private fun getCornerPositions(quad: Quad, containerSize: IntSize, displaySize: IntSize): List { + return listOf( + QuadCoordinateUtils.normalizedToScreen(quad.topLeft, containerSize, displaySize), + QuadCoordinateUtils.normalizedToScreen(quad.topRight, containerSize, displaySize), + QuadCoordinateUtils.normalizedToScreen(quad.bottomRight, containerSize, displaySize), + QuadCoordinateUtils.normalizedToScreen(quad.bottomLeft, containerSize, displaySize) + ) + } + + + private fun clampPoint(point: Point): Point { + return Point( + point.x.coerceIn(0.0, 1.0), + point.y.coerceIn(0.0, 1.0) + ) + } + + private operator fun Point.plus(other: Point): Point { + return Point(this.x + other.x, this.y + other.y) + } +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadOverlay.kt b/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadOverlay.kt new file mode 100644 index 0000000..d5fd193 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/crop/QuadOverlay.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.fairscan.imageprocessing.Point +import org.fairscan.imageprocessing.Quad +import org.fairscan.imageprocessing.scaledTo + +@Composable +fun QuadOverlay( + quad: Quad, + containerSize: IntSize, + displaySize: IntSize, + modifier: Modifier = Modifier +) { + val quadColor = MaterialTheme.colorScheme.primary + val handleColor = quadColor.copy(alpha = 0.5f) + + Canvas(modifier = modifier.fillMaxSize()) { + val scaledQuad = quad.scaledTo( + fromWidth = 1, + fromHeight = 1, + toWidth = displaySize.width, + toHeight = displaySize.height + ) + + val offset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize) + val corners = listOf( + scaledQuad.topLeft.toOffset(), + scaledQuad.topRight.toOffset(), + scaledQuad.bottomRight.toOffset(), + scaledQuad.bottomLeft.toOffset() + ).map { it.copy(x = it.x + offset.width, y = it.y + offset.height) } + + // Draw edges + for (i in 0 until 4) { + drawLine( + color = quadColor, + start = corners[i], + end = corners[(i + 1) % 4], + strokeWidth = 10.0f + ) + } + + // Draw corner handles + corners.forEach { corner -> + drawCircle( + color = handleColor, + radius = QuadEditingHandler.CORNER_RADIUS, + center = corner + ) + } + + } +} + +fun Point.toOffset() = Offset(x.toFloat(), y.toFloat()) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index a3e3bbc..b10c2b1 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Contrast +import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.RotateLeft @@ -98,6 +99,7 @@ fun DocumentScreen( onDeleteImage: () -> Unit, onRotateImage: (Boolean) -> Unit, onToggleColorMode: () -> Unit, + onCropClick: () -> Unit, onPageReorder: (String, Int) -> Unit, onPageSelected: (Int) -> Unit, ) { @@ -122,7 +124,6 @@ fun DocumentScreen( listState = listState, showPageNumbers = true, ), - onBack = navigation.back, bottomBar = { BottomBar(onExportClick, navigation.toCameraScreen) }, @@ -132,6 +133,7 @@ fun DocumentScreen( { showDeletePageDialog.value = true }, onRotateImage, onToggleColorMode, + onCropClick, modifier ) if (showDeletePageDialog.value) { @@ -150,6 +152,7 @@ private fun DocumentPreview( onDeleteImage: () -> Unit, onRotateImage: (Boolean) -> Unit, onToggleColorMode: () -> Unit, + onCropClick: () -> Unit, modifier: Modifier, ) { val currentPageIndex = uiState.currentPageIndex @@ -194,15 +197,12 @@ private fun DocumentPreview( CircularProgressIndicator() } } - uiState.currentPage?.colorMode?.let { - ColorModeButton( - currentColorMode = it, - onToggle = { onToggleColorMode() }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(8.dp) - ) - } + EditButtons( + uiState, + onToggleColorMode, + onCropClick, + modifier = Modifier.align(Alignment.BottomStart) + ) RotationButtons(onRotateImage, Modifier.align(Alignment.BottomCenter)) SecondaryActionButton( Icons.Outlined.Delete, @@ -253,6 +253,31 @@ fun RotationButtons( } } +@Composable +fun EditButtons( + uiState: DocumentUiState, + onToggleColorMode: () -> Unit, + onCropClick: () -> Unit, + modifier: Modifier +) { + Row(modifier = modifier.padding(8.dp)) { + uiState.currentPage?.colorMode?.let { + ColorModeButton( + currentColorMode = it, + onToggle = { onToggleColorMode() }, + ) + } + Spacer(Modifier.width(8.dp)) + if (uiState.currentPage?.canBeCropped ?: false) { + SecondaryActionButton( + icon = Icons.Default.Crop, + contentDescription = stringResource(R.string.crop), + onClick = onCropClick, + ) + } + } +} + @Composable fun ColorModeButton( currentColorMode: ColorMode, @@ -346,14 +371,15 @@ fun DocumentScreenPreview() { listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(), LocalContext.current ) - val key = PageViewKey("123", Rotation.R0, null) + val key = PageViewKey("123", Rotation.R0, null, 0) DocumentScreen( - uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR), document), + uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document), navigation = dummyNavigation(), onExportClick = {}, onDeleteImage = { }, onRotateImage = { _ -> }, onToggleColorMode = { }, + onCropClick = { }, onPageReorder = { _,_ -> }, onPageSelected = { _ -> }, ) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt index 5c7d4fd..dff4c07 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt @@ -29,5 +29,6 @@ data class CurrentPageUiState( val key: PageViewKey, val bitmap: Bitmap?, val colorMode: ColorMode?, + val canBeCropped: Boolean = false, val isLoading: Boolean = false, ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8fe6990..98dbb39 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2,6 +2,7 @@ عن أضف صفحة تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا. + تطبيق ارجع رُفض إذن الوصول إلى الكاميرا يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المستند الحالي. @@ -17,6 +18,7 @@ نُسخت السجلات إلى الحافظة انسخ السجلات يجهز التصدير… + اقتصاص احذف الصفحة هل تريد حذف هذه الصفحة؟ مطوّر @@ -27,7 +29,7 @@ المزود: %1$s لم يعد من الممكن الوصول إلى مجلد التصدير المحدّد. يُرجى اختيار مجلد آخر. لم يُعثر على تطبيق لفتح هذا الملف - لم يكتشف أي مستند + حدث خطأ فشل حفظ الملف صدِّر صدِّر ك %1$s diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 04d7f35..fc03e1c 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2,6 +2,7 @@ O aplikaci Přidat stránku Jednoduchá a respektující aplikace pro skenování vašich dokumentů + Použít Zpět Byl odepřen přístup k fotoaparátu Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když zavřete stávající dokument. @@ -17,6 +18,7 @@ Protokoly zkopírovány do schránky Kopírovat protokoly Příprava exportu… + Oříznout Smazat stránku Chcete smazat tuto stránku? Vývojář @@ -27,7 +29,7 @@ Poskytovatel: %1$s Vybraná složka pro export již není dostupná. Vyberte prosím jinou složku. Nebyla nalezena žádná aplikace pro otevření tohoto souboru - Nebyl rozpoznán žádná dokument + Došlo k chybě Soubor se nepodařilo uložit Exportovat Exportovat jako %1$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8d6a16c..e1e0f2a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,6 +2,7 @@ Über Seite hinzufügen Eine einfache und respektvolle App zum Scannen Ihrer Dokumente. + Anwenden Zurück Kamerazugriff wurde verweigert Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Dokuments gelöscht. @@ -17,6 +18,7 @@ Logs in die Zwischenablage kopiert Logs kopieren Export wird vorbereitet… + Zuschneiden Seite löschen Möchten Sie diese Seite löschen? Entwickler @@ -27,7 +29,7 @@ Anbieter: %1$s Der ausgewählte Exportordner ist nicht mehr zugänglich. Bitte wählen Sie einen anderen Ordner. Keine App zum Öffnen dieser Datei gefunden - Kein Dokument erkannt + Ein Fehler ist aufgetreten Datei konnte nicht gespeichert werden Exportieren Als %1$s exportieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6837d41..10a664c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2,6 +2,7 @@ Acerca de Agregar página Una aplicación sencilla y respetuosa para escanear tus documentos. + Aplicar Atrás Permiso de cámara denegado La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el documento actual. @@ -17,6 +18,7 @@ Registros copiados al portapapeles Copiar registros Preparando la exportación… + Recortar Eliminar página ¿Quieres eliminar esta página? Desarrollador @@ -27,7 +29,7 @@ Proveedor: %1$s La carpeta de exportación seleccionada ya no es accesible. Por favor, elija otra carpeta. No se encontró ninguna aplicación para abrir este archivo - No se detectó ningún documento + Se produjo un error No se pudo guardar el archivo Exportar Exportar como %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 25b62f3..43e82f1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2,6 +2,7 @@ À propos Ajouter une page Une application simple et respectueuse pour scanner vos documents. + Appliquer Retour L\'autorisation d\'accès à la caméra a été refusée L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le document en cours. @@ -17,6 +18,7 @@ Logs copiés dans le presse-papiers Copier les logs Préparation de l’export… + Recadrer Supprimer la page Voulez-vous supprimer cette page ? Développeur @@ -27,7 +29,7 @@ Fournisseur : %1$s Le dossier d’export sélectionné n’est plus accessible. Veuillez choisir un autre dossier. Aucune application trouvée pour ouvrir ce fichier - Aucun document détecté + Une erreur s’est produite Échec de l\'enregistrement du fichier Exporter Exporter en %1$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 7808d63..e1b3826 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -2,6 +2,7 @@ Sobre Engadir páxina Unha aplicación sinxela e respectuosa para escanear os teus documentos. + Aplicar Atrás Permiso da cámara denegado A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o documento actual. @@ -17,6 +18,7 @@ Rexistros copiados ao portapapeis Copiar rexistros Preparando exportación… + Recortar Eliminar páxina Queres eliminar esta páxina? Desenvolvedor @@ -27,7 +29,7 @@ Provedor: %1$s O cartafol de exportación seleccionado xa non é accesible. Escolle outro cartafol. Non se atopou ningunha aplicación para abrir este ficheiro - Non se detectou ningún documento + Produciuse un erro Produciuse un erro ao gardar o ficheiro Exportar Exportar como %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e554232..0778083 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2,6 +2,7 @@ Informazioni Aggiungi pagina Un\'app semplice e rispettosa per scansionare i tuoi documenti. + Applica Indietro Autorizzazione fotocamera negata L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi il documento attuale. @@ -17,6 +18,7 @@ Log copiati negli appunti Copia log Preparazione dell’esportazione… + Ritaglia Elimina pagina Vuoi eliminare questa pagina? Sviluppatore @@ -27,7 +29,7 @@ Provider: %1$s La cartella di esportazione selezionata non è più accessibile. Scegli un’altra cartella. Nessuna app trovata per aprire questo file - Nessun documento rilevato + Si è verificato un errore Impossibile salvare il file Esporta Esporta come %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 32f67c1..39f4b98 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2,6 +2,7 @@ Sobre Adicionar página Um aplicativo simples e respeitoso para digitalizar seus documentos. + Aplicar Voltar Permissão da câmera negada O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar o documento atual. @@ -17,6 +18,7 @@ Registros copiados para a área de transferência Copiar registros Preparando exportação… + Recortar Excluir página Deseja excluir esta página? Desenvolvedor @@ -27,7 +29,7 @@ Provedor: %1$s A pasta de exportação selecionada não está mais acessível. Escolha outra pasta. Nenhum app encontrado para abrir este arquivo - Nenhum documento detectado + Ocorreu um erro Falha ao salvar o arquivo Exportar Exportar como %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2edb8f8..7196b87 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2,6 +2,7 @@ О приложении Добавить страницу Простое и открытое приложение для сканирования Ваших документов. + Применить Назад В доступе к камере отказано Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании работы с текущим документом. @@ -17,6 +18,7 @@ Журналы скопированы в буфер обмена Копировать журналы Подготовка экспорта… + Обрезать Удалить страницу Вы желаете удалить эту страницу? Разработчик @@ -27,7 +29,7 @@ Провайдер: %1$s Выбранная папка экспорта больше недоступна. Пожалуйста, выберите другую папку. Не найдено приложение для открытия этого файла - Документ не обнаружен + Произошла ошибка Не удалось сохранить файл Экспорт Экспортировать как %1$s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c5650c9..7c43e09 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2,6 +2,7 @@ Hakkında Sayfa ekle Belgelerinizi taramak için basit ve saygılı bir uygulama. + Uygula Geri Kamera izni reddedildi Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve mevcut belgeyi kapattığınızda silinir. @@ -17,6 +18,7 @@ Günlükler panoya kopyalandı Günlükleri kopyala Dışa aktarma hazırlanıyor… + Kırp Sayfayı sil Bu sayfayı silmek istiyor musunuz? Geliştirici @@ -27,7 +29,7 @@ Sağlayıcı: %1$s Seçilen dışa aktarma dizini artık erişilebilir değil. Lütfen başka bir dizin seçin. No app found to open this file - No document detected + Bir hata oluştu Failed to save file Dışa aktar %1$s olarak dışa aktar diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index eb5c906..624c8ad 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2,6 +2,7 @@ 關於 新增頁面 一個簡單且尊重隱私的文件掃描應用程式。 + 套用 返回 已拒絕相機權限 此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前文件時刪除。 @@ -17,6 +18,7 @@ 日誌已複製到剪貼簿 複製日誌 正在準備匯出… + 裁切 刪除頁面 確定要刪除此頁面嗎? 開發者 @@ -27,7 +29,7 @@ 提供者:%1$s 所選的匯出目錄已無法存取。請選擇其他目錄。 找不到可開啟此檔案的應用程式 - 未偵測到文件 + 發生錯誤 儲存檔案失敗 匯出 匯出為 %1$s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3a8e926..6b680e1 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -2,6 +2,7 @@ 关于 添加页面 一个简单且克制的文档扫描应用 + 应用 返回 相机权限被拒绝 应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前文档时将被删除。 @@ -17,6 +18,7 @@ 日志已复制到剪贴板 复制日志 正在准备导出… + 裁剪 删除页面 是否要删除此页面? 开发者 @@ -27,7 +29,7 @@ 提供方:%1$s 所选的导出目录已无法访问。请选择其他目录。 未找到可打开此文件的应用 - 未检测到任何文档 + 发生错误 无法保存文件 导出 导出为 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 951ba30..adc2827 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ Add page FairScan A simple and respectful application to scan your documents. + Apply Back Camera permission was denied The app requires camera access to scan documents. Captured images are stored only on this device and will be deleted when you close the current document. @@ -18,6 +19,7 @@ Logs copied to clipboard Copy logs Preparing export… + Crop Delete page Do you want to delete this page? Developer @@ -31,7 +33,7 @@ Failed to launch system file picker on this device Failed to set export directory No app found to open this file - No document detected + An error occurred Failed to save file Export Export as %1$s diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index 29b9f8a..28ed97e 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -26,6 +26,7 @@ import org.assertj.core.api.Assertions.assertThat import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.Rotation.R0 import org.fairscan.app.domain.Rotation.R180 import org.fairscan.app.domain.Rotation.R270 @@ -62,7 +63,7 @@ class ImageRepositoryTest { fun repo( rotate: (Jpeg, Int) -> Jpeg = { input, _ -> input }, resizeToThumbnail: (Jpeg) -> Jpeg = { input -> jpeg(input.bytes[0]) }, - process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ -> + process: (Jpeg, Quad, Rotation, ColorMode) -> Jpeg = { _, _, _, _ -> throw UnsupportedOperationException() } ): ImageRepository { @@ -71,8 +72,12 @@ class ImageRepositoryTest { rotate(input, rotationDegrees) override fun resizeToThumbnail(input: Jpeg): Jpeg = resizeToThumbnail(input) - override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg = - process(source, metadata, colorMode) + override fun process( + source: Jpeg, + normalizedQuad: Quad, + baseRotation: Rotation, + colorMode: ColorMode + ): Jpeg = process(source, normalizedQuad, baseRotation, colorMode) } return ImageRepository(getFilesDir(), transformations, testScope) @@ -86,7 +91,7 @@ class ImageRepositoryTest { repo.add(jpeg, jpeg(51), metadata1, COLOR) assertThat(repo.imageIds()).hasSize(1) val id = repo.imageIds()[0] - val key = PageViewKey(id, R0, COLOR) + val key = PageViewKey(id, R0, COLOR, 0) assertThat(repo.jpegBytes(key)).isEqualTo(jpeg) assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) @@ -153,7 +158,7 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes) + assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test @@ -182,14 +187,14 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes) + assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test fun `should return null on invalid id`() = runTest { val repo = repo() assertThat(repo.imageIds()).isEmpty() - assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR))).isNull() + assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR, 0))).isNull() } @Test @@ -239,7 +244,7 @@ class ImageRepositoryTest { fun setColorMode_should_process_and_update_metadata() = runTest { val jpeg1 = jpeg(10) val repo = repo( - process = { jpeg ,meta, mode -> + process = { _, _ , _, mode -> assertThat(mode).isEqualTo(GRAYSCALE) jpeg(41) } @@ -249,7 +254,7 @@ class ImageRepositoryTest { val id = repo.pages().first().id repo.setColorMode(id, GRAYSCALE) assertThat(repo.pages().first().colorMode).isEqualTo(GRAYSCALE) - val key = PageViewKey(id, R0, GRAYSCALE) + val key = PageViewKey(id, R0, GRAYSCALE, 0) assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(41)) } @@ -257,7 +262,7 @@ class ImageRepositoryTest { fun setColorMode_should_not_run_twice_in_parallel() = runTest { var processCalls = 0 val repo = repo( - process = { _, _, _ -> + process = { _, _, _, _ -> processCalls++ runBlocking { delay(10) } jpeg(1) @@ -269,7 +274,7 @@ class ImageRepositoryTest { launch { repo.setColorMode(id, GRAYSCALE) } launch { repo.setColorMode(id, GRAYSCALE) } } - val key = PageViewKey(id, R0, GRAYSCALE) + val key = PageViewKey(id, R0, GRAYSCALE, 0) assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(1)) assertThat(processCalls).isEqualTo(1) } @@ -307,11 +312,11 @@ class ImageRepositoryTest { fun metadata() { val quad = quad1.toSerializable() - assertThat(PageV2("1", 0, 0, null,true).toMetadata()).isNull() - assertThat(PageV2("1", 0, 0, quad, null).toMetadata()).isNull() + assertThat(PageV2("1", 0, 0, quad = null, isColored = true).toMetadata()).isNull() + assertThat(PageV2("1", 0, 0, quad).toMetadata()).isNull() listOf(true, false).forEach { isColored -> - val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata() + val metadata = PageV2("1", 0, 0, quad, isColored = isColored).toMetadata() assertThat(metadata).isNotNull() assertThat(metadata!!.autoColorMode).isEqualTo( if (isColored) COLOR else GRAYSCALE diff --git a/app/src/test/java/org/fairscan/app/ui/screens/crop/EditPageScreenStateTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/crop/EditPageScreenStateTest.kt new file mode 100644 index 0000000..6c4630f --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/crop/EditPageScreenStateTest.kt @@ -0,0 +1,356 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +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 = CropScreenState() + + 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 = CropScreenState() + + state.updateQuad(testQuad) + assertThat(state.editableQuad).isEqualTo(testQuad) + + state.updateQuad(updatedQuad) + assertThat(state.editableQuad).isEqualTo(updatedQuad) + } + + @Test + fun cornerDragging_managesStateCorrectly() { + val state = CropScreenState() + + // 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 = CropScreenState() + + 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 = CropScreenState() + 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 = CropScreenState() + + 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 = CropScreenState() + + state.onTouchDown(Offset(50f, 50f)) + + assertThat(state.isTouching).isTrue() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + } + + @Test + fun onTouchDown_overwritesPreviousTouchDown() { + val state = CropScreenState() + 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 = CropScreenState() + state.onTouchDown(Offset(100f, 200f), cornerIndex = 1) + + state.onTouchUp() + + assertThat(state.isTouching).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + } + + @Test + fun onTouchUp_preservesDragPosition() { + val state = CropScreenState() + 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 = CropScreenState() + + state.onTouchUp() + + assertThat(state.isTouching).isFalse() + assertThat(state.touchDownCornerIndex).isEqualTo(-1) + } + + // ── endDrag ────────────────────────────────────────────────────────────── + + @Test + fun endDrag_preservesDragPosition() { + val state = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + 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 = CropScreenState() + + state.onTouchDown(Offset(10f, 10f), cornerIndex = 0) + assertThat(state.touchDownCornerIndex).isEqualTo(0) + state.onTouchUp() + + state.onTouchDown(Offset(90f, 10f), cornerIndex = 1) + assertThat(state.touchDownCornerIndex).isEqualTo(1) + state.onTouchUp() + + state.onTouchDown(Offset(90f, 90f), cornerIndex = 2) + assertThat(state.touchDownCornerIndex).isEqualTo(2) + state.onTouchUp() + } +} diff --git a/app/src/test/java/org/fairscan/app/ui/screens/crop/MagnifyingGlassTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/crop/MagnifyingGlassTest.kt new file mode 100644 index 0000000..7ffdc41 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/crop/MagnifyingGlassTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import androidx.compose.ui.geometry.Offset +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +/** Acceptable offset for float comparisons **/ +private val FLOAT_OFFSET = org.assertj.core.data.Offset.offset(0.0001f) + +class MagnifyingGlassTest { + + private val configPx = LoupeLayoutConfig( + loupeRadius = 150f, + verticalOffset = 200f, + screenMargin = 20f, + ) + private val containerWidth = 1080f + + @Test + fun abovePlacement_whenPlentyOfRoomAbove() { + // Finger in the middle of the screen, plenty of room everywhere + val drag = Offset(540f, 800f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Expected Y: 800 - 200 - 150 = 450 + assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET) + // X should stay centred on finger + assertThat(result.x).isCloseTo(540f, FLOAT_OFFSET) + } + + @Test + fun abovePlacement_clampsXToLeftEdge() { + // Finger very close to the left edge + val drag = Offset(50f, 800f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // X should be clamped to screenMargin + loupeRadius = 20 + 150 = 170 + assertThat(result.x).isCloseTo(170f, FLOAT_OFFSET) + // Still placed above + assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET) + } + + @Test + fun abovePlacement_clampsXToRightEdge() { + // Finger very close to the right edge + val drag = Offset(1060f, 800f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // X should be clamped to containerWidth - screenMargin - loupeRadius = 1080 - 20 - 150 = 910 + assertThat(result.x).isCloseTo(910f, FLOAT_OFFSET) + // Still placed above + assertThat(result.y).isCloseTo(450f, FLOAT_OFFSET) + } + + @Test + fun leftPlacement_whenNotEnoughRoomAbove() { + // Finger near the top: Y must be small enough that above placement fails + // above center Y = dragY - verticalOffset - loupeRadius + // condition: aboveCenterY - loupeRadius >= screenMargin + // => dragY - 200 - 150 - 150 >= 20 => dragY >= 520 + // Use dragY = 300 (not enough room above) + // Finger at centre-X so there IS room to the left + val drag = Offset(540f, 300f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Left center X = 540 - 200 - 150 = 190 + // leftCenterX - loupeRadius = 190 - 150 = 40 >= 20 ✓ + assertThat(result.x).isCloseTo(190f, FLOAT_OFFSET) + // Y should equal dragY (clamped, but 300 > screenMargin + loupeRadius = 170) + assertThat(result.y).isCloseTo(300f, FLOAT_OFFSET) + } + + @Test + fun leftPlacement_clampsYToTopEdge() { + // Finger at very top and far right (no room above, room to left) + val drag = Offset(540f, 100f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Left placement: X = 540 - 200 - 150 = 190 (room check: 190 - 150 = 40 >= 20 ✓) + assertThat(result.x).isCloseTo(190f, FLOAT_OFFSET) + // Y clamped to screenMargin + loupeRadius = 20 + 150 = 170 + assertThat(result.y).isCloseTo(170f, FLOAT_OFFSET) + } + + @Test + fun rightPlacement_whenNoRoomAboveOrLeft() { + // Finger near top-left corner: not enough room above AND not enough room on the left + // For left to fail: leftCenterX - loupeRadius < screenMargin + // leftCenterX = dragX - 200 - 150 = dragX - 350 + // leftCenterX - 150 = dragX - 500 < 20 => dragX < 520 + // Also not enough room above: dragY < 520 + val drag = Offset(100f, 300f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Right center X = 100 + 200 + 150 = 450 + // Clamped: min(450, 1080 - 20 - 150) = min(450, 910) = 450 + assertThat(result.x).isCloseTo(450f, FLOAT_OFFSET) + // Y should equal dragY (300 > 170) + assertThat(result.y).isCloseTo(300f, FLOAT_OFFSET) + } + + @Test + fun rightPlacement_clampsXToRightEdge() { + // Use a narrow container to force right placement with X clamping + val narrowResult = computeLoupeCenter( + Offset(100f, 300f), configPx, 500f + ) + + // Right center X = 100 + 200 + 150 = 450 + // Clamped: min(450, 500 - 20 - 150) = min(450, 330) = 330 + assertThat(narrowResult.x).isCloseTo(330f, FLOAT_OFFSET) + assertThat(narrowResult.y).isCloseTo(300f, FLOAT_OFFSET) + } + + @Test + fun rightPlacement_clampsYToTopEdge() { + // Finger at extreme top-left: Y very small, no room above/left -> right, Y clamped + val drag = Offset(50f, 50f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // above: 50 - 200 - 150 = -300, -300 - 150 = -450 < 20 ✗ + // left: 50 - 200 - 150 = -300, -300 - 150 = -450 < 20 ✗ + // right: 50 + 200 + 150 = 400 + assertThat(result.x).isCloseTo(400f, FLOAT_OFFSET) + // Y clamped to screenMargin + loupeRadius = 170 + assertThat(result.y).isCloseTo(170f, FLOAT_OFFSET) + } + + @Test + fun abovePlacement_exactBoundary() { + // dragY such that aboveCenterY - loupeRadius == screenMargin exactly + // dragY - verticalOffset - loupeRadius - loupeRadius = screenMargin + // dragY = screenMargin + 2*loupeRadius + verticalOffset = 20 + 300 + 200 = 520 + val drag = Offset(540f, 520f) + val result = computeLoupeCenter(drag, configPx, containerWidth) + + // Should still place above (condition uses >=) + val expectedY = 520f - 200f - 150f // = 170 + assertThat(result.y).isCloseTo(expectedY, FLOAT_OFFSET) + assertThat(result.x).isCloseTo(540f, FLOAT_OFFSET) + } +} diff --git a/app/src/test/java/org/fairscan/app/ui/screens/crop/QuadCoordinateUtilsTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/crop/QuadCoordinateUtilsTest.kt new file mode 100644 index 0000000..0f0873f --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/crop/QuadCoordinateUtilsTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset as AssertJOffset +import org.fairscan.imageprocessing.Point +import org.junit.Test + +class QuadCoordinateUtilsTest { + + @Test + fun calculateDisplaySize_scalesCorrectlyForVariousAspectRatios() { + // Wider image than container - fits to width + var result = QuadCoordinateUtils.calculateDisplaySize(1920, 1080, IntSize(1000, 800)) + assertThat(result.width).isEqualTo(1000) + assertThat(result.height).isCloseTo(562, AssertJOffset.offset(1)) + + // Taller image than container - fits to height + result = QuadCoordinateUtils.calculateDisplaySize(1080, 1920, IntSize(1000, 800)) + assertThat(result.height).isEqualTo(800) + assertThat(result.width).isCloseTo(450, AssertJOffset.offset(1)) + + // Square image in square container + result = QuadCoordinateUtils.calculateDisplaySize(500, 500, IntSize(1000, 1000)) + assertThat(result.width).isEqualTo(1000) + assertThat(result.height).isEqualTo(1000) + + // Same aspect ratio - fills container + result = QuadCoordinateUtils.calculateDisplaySize(800, 600, IntSize(400, 300)) + assertThat(result.width).isEqualTo(400) + assertThat(result.height).isEqualTo(300) + } + + @Test + fun normalizedToScreen_convertsPointsWithCorrectOffset() { + val containerSize = IntSize(1000, 800) + val displaySize = IntSize(800, 600) + // Offset: (1000-800)/2 = 100 for x, (800-600)/2 = 100 for y + + // Top-left corner (0,0) + var result = QuadCoordinateUtils.normalizedToScreen(Point(0.0, 0.0), containerSize, displaySize) + assertThat(result.x).isCloseTo(100f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(100f, AssertJOffset.offset(0.1f)) + + // Center (0.5, 0.5) -> x: 0.5*800+100=500, y: 0.5*600+100=400 + result = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize) + assertThat(result.x).isCloseTo(500f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(400f, AssertJOffset.offset(0.1f)) + + // Bottom-right corner (1,1) -> x: 1.0*800+100=900, y: 1.0*600+100=700 + result = QuadCoordinateUtils.normalizedToScreen(Point(1.0, 1.0), containerSize, displaySize) + assertThat(result.x).isCloseTo(900f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(700f, AssertJOffset.offset(0.1f)) + + // No offset when sizes match + result = QuadCoordinateUtils.normalizedToScreen(Point(0.0, 0.0), IntSize(800, 600), IntSize(800, 600)) + assertThat(result.x).isCloseTo(0f, AssertJOffset.offset(0.1f)) + assertThat(result.y).isCloseTo(0f, AssertJOffset.offset(0.1f)) + } + + @Test + fun screenDeltaToNormalized_convertsDeltas() { + val displaySize = IntSize(800, 600) + + // Positive delta: 80/800=0.1, 60/600=0.1 + var result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(80f, 60f), displaySize) + assertThat(result.x).isCloseTo(0.1f, AssertJOffset.offset(0.001f)) + assertThat(result.y).isCloseTo(0.1f, AssertJOffset.offset(0.001f)) + + // Zero delta + result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(0f, 0f), displaySize) + assertThat(result.x).isEqualTo(0f) + assertThat(result.y).isEqualTo(0f) + + // Negative delta: -160/800=-0.2, -120/600=-0.2 + result = QuadCoordinateUtils.screenDeltaToNormalized(Offset(-160f, -120f), displaySize) + assertThat(result.x).isCloseTo(-0.2f, AssertJOffset.offset(0.001f)) + assertThat(result.y).isCloseTo(-0.2f, AssertJOffset.offset(0.001f)) + } + + @Test + fun getImageOffset_calculatesCorrectOffsets() { + // Standard offset + var result = QuadCoordinateUtils.getImageOffset(IntSize(1000, 800), IntSize(800, 600)) + assertThat(result.width).isEqualTo(100) + assertThat(result.height).isEqualTo(100) + + // Same size - zero offset + result = QuadCoordinateUtils.getImageOffset(IntSize(800, 600), IntSize(800, 600)) + assertThat(result.width).isEqualTo(0) + assertThat(result.height).isEqualTo(0) + + // Asymmetric offset (only horizontal) + result = QuadCoordinateUtils.getImageOffset(IntSize(1000, 600), IntSize(800, 600)) + assertThat(result.width).isEqualTo(100) + assertThat(result.height).isEqualTo(0) + } +} diff --git a/app/src/test/java/org/fairscan/app/ui/screens/crop/QuadEditingHandlerTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/crop/QuadEditingHandlerTest.kt new file mode 100644 index 0000000..1855113 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/ui/screens/crop/QuadEditingHandlerTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.app.ui.screens.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset as AssertJOffset +import org.fairscan.imageprocessing.Point +import org.fairscan.imageprocessing.Quad +import org.junit.Before +import org.junit.Test + +class QuadEditingHandlerTest { + + private lateinit var handler: QuadEditingHandler + private val containerSize = IntSize(1000, 800) + private val displaySize = IntSize(800, 600) + + private val centeredQuad = Quad( + topLeft = Point(0.2, 0.2), + topRight = Point(0.8, 0.2), + bottomRight = Point(0.8, 0.8), + bottomLeft = Point(0.2, 0.8) + ) + + @Before + fun setUp() { + handler = QuadEditingHandler() + } + + private fun closeTopCornersQuadForTouchRadius(): Quad { + val radiusPx = QuadEditingHandler.CORNER_TOUCH_RADIUS + // Keep the two top corners comfortably inside each other's touch radius. + val separationPx = radiusPx * 0.8f + val normalizedHalfSeparation = (separationPx / displaySize.width) / 2.0 + val centerX = 0.5 + return Quad( + topLeft = Point(centerX - normalizedHalfSeparation, 0.2), + topRight = Point(centerX + normalizedHalfSeparation, 0.2), + bottomRight = Point(0.8, 0.8), + bottomLeft = Point(0.2, 0.8) + ) + } + + @Test + fun findTouchedCorner_detectsAllCornersAndMisses() { + // All four corners should be detected + val corners = listOf(centeredQuad.topLeft, centeredQuad.topRight, centeredQuad.bottomRight, centeredQuad.bottomLeft) + corners.forEachIndexed { index, corner -> + val touchPos = QuadCoordinateUtils.normalizedToScreen(corner, containerSize, displaySize) + assertThat(handler.findTouchedCorner(touchPos, centeredQuad, containerSize, displaySize)).isEqualTo(index) + } + + // Near corner (within radius) should also be detected + val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(centeredQuad.topLeft, containerSize, displaySize) + val nearTouch = Offset(topLeftScreen.x + 20f, topLeftScreen.y + 15f) + assertThat(handler.findTouchedCorner(nearTouch, centeredQuad, containerSize, displaySize)).isEqualTo(0) + + // Outside visual corner radius but inside expanded touch radius. + val outsideVisualButTouchable = Offset(topLeftScreen.x + 70f, topLeftScreen.y) + assertThat((outsideVisualButTouchable - topLeftScreen).getDistance()) + .isGreaterThan(QuadEditingHandler.CORNER_RADIUS) + assertThat(handler.findTouchedCorner(outsideVisualButTouchable, centeredQuad, containerSize, displaySize)) + .isEqualTo(0) + + // Far from corners should return -1 + val centerTouch = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize) + assertThat(handler.findTouchedCorner(centerTouch, centeredQuad, containerSize, displaySize)).isEqualTo(-1) + } + + @Test + fun findTouchedCorner_selectsClosestCornerWhenMultipleAreInTouchRadius() { + // Two corners close together so their touch areas overlap. + val closeTopCornersQuad = closeTopCornersQuadForTouchRadius() + val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topLeft, containerSize, displaySize) + val topRightScreen = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topRight, containerSize, displaySize) + val towardCornerOffset = QuadEditingHandler.CORNER_TOUCH_RADIUS * 0.2f + + // Touch a bit to the left of topRight — inside both radii but closer to topRight (index 1). + val touchCloserToTopRight = Offset(topRightScreen.x - towardCornerOffset, topRightScreen.y) + assertThat(handler.findTouchedCorner(touchCloserToTopRight, closeTopCornersQuad, containerSize, displaySize)) + .isEqualTo(1) + + // Touch a bit to the right of topLeft — inside both radii but closer to topLeft (index 0). + val touchCloserToTopLeft = Offset(topLeftScreen.x + towardCornerOffset, topLeftScreen.y) + assertThat(handler.findTouchedCorner(touchCloserToTopLeft, closeTopCornersQuad, containerSize, displaySize)) + .isEqualTo(0) + + // Exact midpoint — equal distance to both; either index 0 or 1 is acceptable. + val midpointTouch = Offset((topLeftScreen.x + topRightScreen.x) / 2f, topLeftScreen.y) + assertThat(handler.findTouchedCorner(midpointTouch, closeTopCornersQuad, containerSize, displaySize)) + .isIn(0, 1) + } + + @Test + fun findTouchedCornerCandidates_returnsAllCornersInRadiusSortedByDistance() { + // Single corner in range: only that corner returned. + val topLeftScreen = QuadCoordinateUtils.normalizedToScreen(centeredQuad.topLeft, containerSize, displaySize) + val single = handler.findTouchedCornerCandidates(topLeftScreen, centeredQuad, containerSize, displaySize) + assertThat(single).containsExactly(0) + + // No corner in range: empty list. + val farAway = QuadCoordinateUtils.normalizedToScreen(Point(0.5, 0.5), containerSize, displaySize) + assertThat(handler.findTouchedCornerCandidates(farAway, centeredQuad, containerSize, displaySize)).isEmpty() + + // Use a quad whose top two corners are spaced relative to CORNER_TOUCH_RADIUS. + val closeTopCornersQuad = closeTopCornersQuadForTouchRadius() + val closeTL = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topLeft, containerSize, displaySize) + val closeTR = QuadCoordinateUtils.normalizedToScreen(closeTopCornersQuad.topRight, containerSize, displaySize) + val towardCornerOffset = QuadEditingHandler.CORNER_TOUCH_RADIUS * 0.2f + + // Touch at midpoint: both in range, order may vary — but both must be present. + val midpoint = Offset((closeTL.x + closeTR.x) / 2f, closeTL.y) + val bothCandidates = handler.findTouchedCornerCandidates(midpoint, closeTopCornersQuad, containerSize, displaySize) + assertThat(bothCandidates).containsExactlyInAnyOrder(0, 1) + + // Touch closer to topRight (index 1): topRight must be first in the list. + val touchNearTR = Offset(closeTR.x - towardCornerOffset, closeTR.y) + val overlap = handler.findTouchedCornerCandidates(touchNearTR, closeTopCornersQuad, containerSize, displaySize) + assertThat(overlap.first()).isEqualTo(1) // topRight is closest + assertThat(overlap).contains(0) // topLeft also a candidate + + // Touch closer to topLeft (index 0): topLeft must be first. + val touchNearTL = Offset(closeTL.x + towardCornerOffset, closeTL.y) + val overlapTL = handler.findTouchedCornerCandidates(touchNearTL, closeTopCornersQuad, containerSize, displaySize) + assertThat(overlapTL.first()).isEqualTo(0) + } + + @Test + fun updateQuadCorner_movesCorrectCornerAndClampsTooBounds() { + // Move each corner and verify only that corner changes + val deltas = listOf(Offset(0.1f, 0.1f), Offset(-0.1f, 0.1f), Offset(-0.1f, -0.1f), Offset(0.1f, -0.1f)) + val expectedPositions = listOf(Point(0.3, 0.3), Point(0.7, 0.3), Point(0.7, 0.7), Point(0.3, 0.7)) + + deltas.forEachIndexed { index, delta -> + val result = handler.updateQuadCorner(centeredQuad, index, delta) + val movedCorner = when (index) { + 0 -> result.topLeft + 1 -> result.topRight + 2 -> result.bottomRight + else -> result.bottomLeft + } + assertThat(movedCorner.x).isCloseTo(expectedPositions[index].x, AssertJOffset.offset(0.001)) + assertThat(movedCorner.y).isCloseTo(expectedPositions[index].y, AssertJOffset.offset(0.001)) + } + + // Invalid index returns unchanged quad + assertThat(handler.updateQuadCorner(centeredQuad, 5, Offset(0.1f, 0.1f))).isEqualTo(centeredQuad) + + // Zero delta returns unchanged quad + assertThat(handler.updateQuadCorner(centeredQuad, 0, Offset(0f, 0f))).isEqualTo(centeredQuad) + + // Clamping to min bounds (0) + var result = handler.updateQuadCorner(centeredQuad, 0, Offset(-0.5f, -0.5f)) + assertThat(result.topLeft.x).isEqualTo(0.0) + assertThat(result.topLeft.y).isEqualTo(0.0) + + // Clamping to max bounds (1) + result = handler.updateQuadCorner(centeredQuad, 2, Offset(0.5f, 0.5f)) + assertThat(result.bottomRight.x).isEqualTo(1.0) + assertThat(result.bottomRight.y).isEqualTo(1.0) + } + + + // ── Convexity enforcement tests ────────────────────────────────────── + + @Test + fun updateQuadCorner_rejectsConcaveResult() { + // Drag the topLeft corner past the diagonal to create a concave quad. + // Moving topLeft far to the right and down should make the quad concave. + val result = handler.updateQuadCorner(centeredQuad, 0, Offset(0.7f, 0.7f)) + // The result should still be convex, meaning the move was rejected + // (the original quad is returned). + assertThat(result).isEqualTo(centeredQuad) + } + + @Test + fun updateQuadCorner_allowsConvexResult() { + // A small move that keeps the quad convex should be allowed. + val result = handler.updateQuadCorner(centeredQuad, 0, Offset(0.05f, 0.05f)) + // The corner should have moved. + assertThat(result.topLeft.x).isCloseTo(0.25, AssertJOffset.offset(0.001)) + assertThat(result.topLeft.y).isCloseTo(0.25, AssertJOffset.offset(0.001)) + } + + + @Test + fun updateQuadCorner_allowsFixingConcaveQuad() { + // Start with a concave quad (topLeft is pushed too far inward). + val concaveQuad = Quad( + topLeft = Point(0.7, 0.7), // past the center, making it concave + topRight = Point(0.8, 0.2), + bottomRight = Point(0.8, 0.8), + bottomLeft = Point(0.2, 0.8) + ) + // Move topLeft back outward to restore convexity. + val result = handler.updateQuadCorner(concaveQuad, 0, Offset(-0.5f, -0.5f)) + // The move should be allowed because the result is convex. + assertThat(result.topLeft.x).isCloseTo(0.2, AssertJOffset.offset(0.001)) + assertThat(result.topLeft.y).isCloseTo(0.2, AssertJOffset.offset(0.001)) + } + + + @Test + fun updateQuadCorner_rejectsMoveAlongEdgeThatCreatesConcavity() { + // Start with a nearly-flat quad that's still convex. + val narrowQuad = Quad( + topLeft = Point(0.2, 0.2), + topRight = Point(0.8, 0.2), + bottomRight = Point(0.8, 0.3), + bottomLeft = Point(0.2, 0.3) + ) + // Drag bottomLeft upward past the top edge — should be rejected. + val result = handler.updateQuadCorner(narrowQuad, 3, Offset(0.0f, -0.2f)) + assertThat(result).isEqualTo(narrowQuad) + } +} diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt index fc6bf2d..0ad1603 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Geometry.kt @@ -47,6 +47,26 @@ data class Quad( Line(bottomLeft, topLeft)) } + /** + * Returns `true` when the four corners form a strictly convex polygon with + * correct winding order (topLeft -> topRight -> bottomRight -> bottomLeft + * clockwise in screen coordinates where y increases downward). + * + * The check computes the cross product at each corner of consecutive edges + * and verifies that all four cross products are strictly positive. + */ + fun isConvex(): Boolean { + val pts = listOf(topLeft, topRight, bottomRight, bottomLeft) + for (i in pts.indices) { + val a = pts[i] + val b = pts[(i + 1) % 4] + val c = pts[(i + 2) % 4] + val cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) + if (cross <= 0.0) return false + } + return true + } + fun rotate90(iterations: Int, imageSize: ImageSize): Quad { val rotatedPoints = listOf( rotate90(topLeft, imageSize, iterations),