New feature: Reorder pages (#20) (#38)

* Page reordering: backend

* Page reordering: user interface

* Improve handling of data inconsistencies
This commit is contained in:
pynicolas
2025-09-27 10:16:18 +02:00
committed by GitHub
parent 189af12c88
commit e93cf0c3a2
13 changed files with 171 additions and 35 deletions

View File

@@ -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<String> {
return fileNames.toList()
private var fileNames: MutableList<String> =
loadFileNames()
private fun loadFileNames(): MutableList<String> {
val filesOnDisk: Set<String> = scanDir.listFiles()
?.filter { it.extension == "jpg" }
?.map { it.name }
?.toSet()
?: emptySet()
val metadataFiles: List<String>? = 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<DocumentMetadata>(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<String> = 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()
}
}

View File

@@ -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 -> {

View File

@@ -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>(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()
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.data
import kotlinx.serialization.Serializable
@Serializable
data class DocumentMetadata(
val version: Int = 1,
val pages: List<Page>
)
@Serializable
data class Page(
val file: String,
)

View File

@@ -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,

View File

@@ -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 = { _,_ -> },
)
}
}

View File

@@ -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<String>,
val pageIds: ImmutableList<String>,
private val imageLoader: (String) -> Bitmap?
) {
fun pageCount(): Int {

View File

@@ -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 = {},

View File

@@ -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))

View File

@@ -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<String>, context: Context): DocumentUiModel {
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
return DocumentUiModel(pageIds) { id ->
context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input)