diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8732d92..1a48ace 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.aboutLibraries) alias(libs.plugins.protobuf) + alias(libs.plugins.kotlin.serialization) } val abiCodes = mapOf( @@ -126,7 +127,9 @@ dependencies { } implementation(libs.icons.extended) implementation(libs.zoomable) + implementation(libs.reorderable) implementation(libs.aboutlibraries.compose.m3) + implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) testImplementation(libs.assertj) diff --git a/app/src/main/java/org/fairscan/app/ImageRepository.kt b/app/src/main/java/org/fairscan/app/ImageRepository.kt index 08e83c3..9c52053 100644 --- a/app/src/main/java/org/fairscan/app/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/ImageRepository.kt @@ -14,6 +14,11 @@ */ package org.fairscan.app +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.json.Json +import org.fairscan.app.data.DocumentMetadata +import org.fairscan.app.data.Page import java.io.File const val SCAN_DIR_NAME = "scanned_pages" @@ -24,19 +29,52 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio if (!exists()) mkdirs() } - val fileNames = scanDir.listFiles() - ?.map { f -> f.name }?.toMutableList() - ?:mutableListOf() + private val metadataFile = File(scanDir, "document.json") - fun imageIds(): List { - return fileNames.toList() + private var fileNames: MutableList = + loadFileNames() + + private fun loadFileNames(): MutableList { + val filesOnDisk: Set = scanDir.listFiles() + ?.filter { it.extension == "jpg" } + ?.map { it.name } + ?.toSet() + ?: emptySet() + + val metadataFiles: List? = loadMetadata() + ?.pages + ?.map { it.file } + + return when { + metadataFiles != null -> metadataFiles + .filter { it in filesOnDisk } + .toMutableList() + else -> filesOnDisk + .sorted() + .toMutableList() + } } + private fun loadMetadata(): DocumentMetadata? = + if (metadataFile.exists()) { + runCatching { + Json.decodeFromString(metadataFile.readText()) + }.getOrNull() + } else null + + private fun saveMetadata() { + val metadata = DocumentMetadata(version = 1, pages = fileNames.map { id -> Page(id) }) + metadataFile.writeText(Json.encodeToString(metadata)) + } + + fun imageIds(): ImmutableList = fileNames.toImmutableList() + fun add(bytes: ByteArray) { val fileName = "${System.currentTimeMillis()}.jpg" val file = File(scanDir, fileName) file.writeBytes(bytes) fileNames.add(fileName) + saveMetadata() } val idRegex = Regex("([0-9]+)(-(90|180|270))?\\.jpg") @@ -57,6 +95,7 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio val index = fileNames.indexOf(id) if (index >= 0) { fileNames[index] = rotatedId + saveMetadata() } delete(id) } @@ -64,17 +103,25 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio } fun getContent(id: String): ByteArray? { - if (fileNames.contains(id)) { - val file = File(scanDir, id) + val file = File(scanDir, id) + if (file.exists()) { return file.readBytes() } return null } + fun movePage(id: String, newIndex: Int) { + if (!fileNames.remove(id)) return + val safeIndex = newIndex.coerceIn(0, fileNames.size) + fileNames.add(safeIndex, id) + saveMetadata() + } + fun delete(id: String) { val file = File(scanDir, id) file.delete() fileNames.remove(id) + saveMetadata() } fun clear() { @@ -82,5 +129,6 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio scanDir.listFiles()?.forEach { file -> file.delete() } + saveMetadata() } } diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 8ac3cf0..75d1e13 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -122,7 +122,8 @@ class MainActivity : ComponentActivity() { initialPage = screen.initialPage, navigation = navigation, onDeleteImage = { id -> viewModel.deletePage(id) }, - onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) } + onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) }, + onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) }, ) } is Screen.Main.Export -> { diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 36c6f46..83985b4 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -87,7 +88,7 @@ class MainViewModel( }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, - initialValue = DocumentUiModel(emptyList(), ::getBitmap) + initialValue = DocumentUiModel(persistentListOf(), ::getBitmap) ) private val _captureState = MutableStateFlow(CaptureState.Idle) @@ -228,6 +229,11 @@ class MainViewModel( } } + fun movePage(id: String, newIndex: Int) { + imageRepository.movePage(id, newIndex) + _pageIds.value = imageRepository.imageIds() + } + fun afterCaptureError() { _captureState.value = CaptureState.Idle } @@ -238,7 +244,7 @@ class MainViewModel( } fun startNewDocument() { - _pageIds.value = listOf() + _pageIds.value = persistentListOf() viewModelScope.launch { imageRepository.clear() } diff --git a/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt new file mode 100644 index 0000000..1d8478f --- /dev/null +++ b/app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Pierre-Yves Nicolas + * + * 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.data + +import kotlinx.serialization.Serializable + +@Serializable +data class DocumentMetadata( + val version: Int = 1, + val pages: List +) + +@Serializable +data class Page( + val file: String, +) diff --git a/app/src/main/java/org/fairscan/app/view/CameraScreen.kt b/app/src/main/java/org/fairscan/app/view/CameraScreen.kt index 318c0ea..9e26182 100644 --- a/app/src/main/java/org/fairscan/app/view/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/view/CameraScreen.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import org.fairscan.app.CameraPermissionState import org.fairscan.app.LiveAnalysisState @@ -157,6 +158,7 @@ fun CameraScreen( CommonPageListState( document = document, onPageClick = { index -> viewModel.navigateTo(Screen.Main.Document(index)) }, + onPageReorder = { id, index -> viewModel.movePage(id, index) }, listState = listState, onLastItemPosition = { offset -> thumbnailCoords.value = offset }, ), @@ -468,9 +470,12 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0 pageListState = CommonPageListState( document = fakeDocument( - listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }, + listOf(1, 2) + .map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" } + .toImmutableList(), LocalContext.current), onPageClick = {}, + onPageReorder = { _,_ -> }, listState = LazyListState(), ), cameraUiState = CameraUiState(pageCount = 4, LiveAnalysisState(), captureState, diff --git a/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt index 3e55e35..9ac93f3 100644 --- a/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/view/DocumentScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.toImmutableList import net.engawapg.lib.zoomable.ZoomState import net.engawapg.lib.zoomable.zoomable import org.fairscan.app.Navigation @@ -66,6 +67,7 @@ fun DocumentScreen( navigation: Navigation, onDeleteImage: (String) -> Unit, onRotateImage: (String, Boolean) -> Unit, + onPageReorder: (String, Int) -> Unit, ) { // TODO Check how often images are loaded val showDeletePageDialog = rememberSaveable { mutableStateOf(false) } @@ -81,7 +83,7 @@ fun DocumentScreen( val listState = rememberLazyListState() LaunchedEffect(currentPageIndex.intValue) { - listState.animateScrollToItem(currentPageIndex.intValue) + listState.scrollToItem(currentPageIndex.intValue) } MyScaffold( @@ -89,6 +91,7 @@ fun DocumentScreen( pageListState = CommonPageListState( document, onPageClick = { index -> currentPageIndex.intValue = index }, + onPageReorder = onPageReorder, currentPageIndex = currentPageIndex.intValue, listState = listState, ), @@ -226,12 +229,13 @@ fun DocumentScreenPreview() { FairScanTheme { DocumentScreen( fakeDocument( - listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }, + listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }.toImmutableList(), LocalContext.current), initialPage = 1, navigation = dummyNavigation(), onDeleteImage = { _ -> }, onRotateImage = { _,_ -> }, + onPageReorder = { _,_ -> }, ) } } diff --git a/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt b/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt index 357008e..0f5dad2 100644 --- a/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt +++ b/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt @@ -15,9 +15,10 @@ package org.fairscan.app.view import android.graphics.Bitmap +import kotlinx.collections.immutable.ImmutableList data class DocumentUiModel( - private val pageIds: List, + val pageIds: ImmutableList, private val imageLoader: (String) -> Bitmap? ) { fun pageCount(): Int { diff --git a/app/src/main/java/org/fairscan/app/view/HomeScreen.kt b/app/src/main/java/org/fairscan/app/view/HomeScreen.kt index 8bd5e5c..db710d5 100644 --- a/app/src/main/java/org/fairscan/app/view/HomeScreen.kt +++ b/app/src/main/java/org/fairscan/app/view/HomeScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.persistentListOf import org.fairscan.app.CameraPermissionState import org.fairscan.app.Navigation import org.fairscan.app.R @@ -284,7 +285,7 @@ fun HomeScreenPreviewWithCurrentDocument() { HomeScreen( cameraPermission = rememberCameraPermissionState(), currentDocument = fakeDocument( - listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"), + persistentListOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"), LocalContext.current), navigation = dummyNavigation(), onClearScan = {}, diff --git a/app/src/main/java/org/fairscan/app/view/PageList.kt b/app/src/main/java/org/fairscan/app/view/PageList.kt index 4aa73f9..a052238 100644 --- a/app/src/main/java/org/fairscan/app/view/PageList.kt +++ b/app/src/main/java/org/fairscan/app/view/PageList.kt @@ -14,6 +14,7 @@ */ package org.fairscan.app.view +import android.annotation.SuppressLint import android.content.res.Configuration import android.graphics.Bitmap import androidx.compose.foundation.Image @@ -31,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -47,12 +49,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState const val PAGE_LIST_ELEMENT_SIZE_DP = 120 data class CommonPageListState( val document: DocumentUiModel, val onPageClick: (Int) -> Unit, + val onPageReorder: (String, Int) -> Unit, val listState: LazyListState, val currentPageIndex: Int? = null, val onLastItemPosition: ((Offset) -> Unit)? = null, @@ -64,12 +69,18 @@ fun CommonPageList( modifier: Modifier = Modifier, ) { val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val reorderableLazyListState = rememberReorderableLazyListState(state.listState) { from, to -> + state.onPageReorder(from.key as String, to.index) + } val content: LazyListScope.() -> Unit = { - items(state.document.pageCount()) { index -> - // TODO Use small images rather than big ones - val image = state.document.load(index) - if (image != null) { - PageThumbnail(image, index, state) + itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item -> + ReorderableItem(reorderableLazyListState, key = item) { _ -> + // TODO Use small images rather than big ones + val image = state.document.load(index) + if (image != null) { + PageThumbnail(image, index, state, Modifier.draggableHandle()) + } } } } @@ -103,33 +114,34 @@ private fun PageThumbnail( image: Bitmap, index: Int, state: CommonPageListState, + @SuppressLint("ModifierParameter") draggableModifier: Modifier, ) { val bitmap = image.asImageBitmap() val isSelected = index == state.currentPageIndex val borderColor = if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent val maxImageSize = PAGE_LIST_ELEMENT_SIZE_DP.dp - var modifier = + var imageModifier = if (bitmap.height > bitmap.width) Modifier.height(maxImageSize) else Modifier.width(maxImageSize) if (index == state.document.lastIndex()) { val density = LocalDensity.current - modifier = modifier.addPositionCallback(state.onLastItemPosition, density, 1.0f) + imageModifier = imageModifier.addPositionCallback(state.onLastItemPosition, density, 1.0f) } Box (modifier = Modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) { Image( bitmap = bitmap, contentDescription = null, - modifier = modifier + modifier = imageModifier .align(Alignment.Center) .padding(4.dp) .border(2.dp, borderColor) .clickable { state.onPageClick(index) } ) Box( - modifier = Modifier + modifier = draggableModifier .padding(8.dp) .align(Alignment.BottomCenter) .background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp)) diff --git a/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt index 618a136..c4cac17 100644 --- a/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt @@ -16,6 +16,8 @@ package org.fairscan.app.view import android.content.Context import android.graphics.BitmapFactory +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import org.fairscan.app.Navigation fun dummyNavigation(): Navigation { @@ -23,10 +25,10 @@ fun dummyNavigation(): Navigation { } fun fakeDocument(): DocumentUiModel { - return DocumentUiModel(listOf()) { _ -> null } + return DocumentUiModel(persistentListOf()) { _ -> null } } -fun fakeDocument(pageIds: List, context: Context): DocumentUiModel { +fun fakeDocument(pageIds: ImmutableList, context: Context): DocumentUiModel { return DocumentUiModel(pageIds) { id -> context.assets.open(id).use { input -> BitmapFactory.decodeStream(input) diff --git a/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt index cd450ae..7558d90 100644 --- a/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt @@ -61,14 +61,21 @@ class ImageRepositoryTest { } @Test - fun `should find existing files at initialization`() { - val bytes = byteArrayOf(101, 102, 103) - val repo1 = repo() - assertThat(repo1.imageIds()).isEmpty() - repo1.add(bytes) - val repo2 = repo() - assertThat(repo2.imageIds()).hasSize(1) - assertThat(repo2.getContent(repo2.imageIds()[0])).isEqualTo(bytes) + fun `should find existing files at initialization with no json`() { + val scanDir = File(getFilesDir(), SCAN_DIR_NAME) + scanDir.mkdirs() + File(scanDir, "1.jpg").writeBytes(byteArrayOf(101, 102, 103)) + assertThat(repo().imageIds()).containsExactly("1.jpg") + } + + @Test + fun `should filter pages in json at initialization`() { + val scanDir = File(getFilesDir(), SCAN_DIR_NAME) + scanDir.mkdirs() + val json = """{"pages":[{"file":"1.jpg"}, {"file":"2.jpg"}]}""" + File(scanDir, "document.json").writeText(json) + File(scanDir, "2.jpg").writeBytes(byteArrayOf(101, 102, 103)) + assertThat(repo().imageIds()).containsExactly("2.jpg") } @Test @@ -117,4 +124,18 @@ class ImageRepositoryTest { val id5 = repo.imageIds().last() assertThat(id5).isEqualTo("$baseId-270.jpg") } + + @Test + fun movePage() { + val repo = repo() + repo.add(byteArrayOf(101)) + repo.add(byteArrayOf(110)) + val id0 = repo.imageIds().first() + val id1 = repo.imageIds().last() + repo.movePage(id1, 0) + assertThat(repo.imageIds()).containsExactly(id1, id0) + + val repo2 = repo() + assertThat(repo2.imageIds()).containsExactly(id1, id0) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7013d17..1124417 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,8 @@ zoomable = "2.8.1" aboutLibraries = "12.2.4" protobuf = "0.9.5" protobufJavaLite = "4.32.0" +kotlinSerialization = "1.9.0" +reorderable = "3.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,7 +53,9 @@ litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata opencv = { group="org.opencv", name="opencv", version.ref = "opencv" } pdfbox = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } +reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" } assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" } @@ -59,7 +63,7 @@ assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" } android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } license = { id = "com.github.hierynomus.license", version.ref = "license" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } -