* Page reordering: backend * Page reordering: user interface * Improve handling of data inconsistencies
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
28
app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt
Normal file
28
app/src/main/java/org/fairscan/app/data/DocumentMetadata.kt
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = { _,_ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user