* Page reordering: backend * Page reordering: user interface * Improve handling of data inconsistencies
This commit is contained in:
@@ -4,6 +4,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.aboutLibraries)
|
alias(libs.plugins.aboutLibraries)
|
||||||
alias(libs.plugins.protobuf)
|
alias(libs.plugins.protobuf)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
val abiCodes = mapOf(
|
val abiCodes = mapOf(
|
||||||
@@ -126,7 +127,9 @@ dependencies {
|
|||||||
}
|
}
|
||||||
implementation(libs.icons.extended)
|
implementation(libs.icons.extended)
|
||||||
implementation(libs.zoomable)
|
implementation(libs.zoomable)
|
||||||
|
implementation(libs.reorderable)
|
||||||
implementation(libs.aboutlibraries.compose.m3)
|
implementation(libs.aboutlibraries.compose.m3)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.assertj)
|
testImplementation(libs.assertj)
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.fairscan.app
|
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
|
import java.io.File
|
||||||
|
|
||||||
const val SCAN_DIR_NAME = "scanned_pages"
|
const val SCAN_DIR_NAME = "scanned_pages"
|
||||||
@@ -24,19 +29,52 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
|
|||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileNames = scanDir.listFiles()
|
private val metadataFile = File(scanDir, "document.json")
|
||||||
?.map { f -> f.name }?.toMutableList()
|
|
||||||
?:mutableListOf()
|
|
||||||
|
|
||||||
fun imageIds(): List<String> {
|
private var fileNames: MutableList<String> =
|
||||||
return fileNames.toList()
|
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) {
|
fun add(bytes: ByteArray) {
|
||||||
val fileName = "${System.currentTimeMillis()}.jpg"
|
val fileName = "${System.currentTimeMillis()}.jpg"
|
||||||
val file = File(scanDir, fileName)
|
val file = File(scanDir, fileName)
|
||||||
file.writeBytes(bytes)
|
file.writeBytes(bytes)
|
||||||
fileNames.add(fileName)
|
fileNames.add(fileName)
|
||||||
|
saveMetadata()
|
||||||
}
|
}
|
||||||
|
|
||||||
val idRegex = Regex("([0-9]+)(-(90|180|270))?\\.jpg")
|
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)
|
val index = fileNames.indexOf(id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
fileNames[index] = rotatedId
|
fileNames[index] = rotatedId
|
||||||
|
saveMetadata()
|
||||||
}
|
}
|
||||||
delete(id)
|
delete(id)
|
||||||
}
|
}
|
||||||
@@ -64,17 +103,25 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getContent(id: String): ByteArray? {
|
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 file.readBytes()
|
||||||
}
|
}
|
||||||
return null
|
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) {
|
fun delete(id: String) {
|
||||||
val file = File(scanDir, id)
|
val file = File(scanDir, id)
|
||||||
file.delete()
|
file.delete()
|
||||||
fileNames.remove(id)
|
fileNames.remove(id)
|
||||||
|
saveMetadata()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -82,5 +129,6 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
|
|||||||
scanDir.listFiles()?.forEach {
|
scanDir.listFiles()?.forEach {
|
||||||
file -> file.delete()
|
file -> file.delete()
|
||||||
}
|
}
|
||||||
|
saveMetadata()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
initialPage = screen.initialPage,
|
initialPage = screen.initialPage,
|
||||||
navigation = navigation,
|
navigation = navigation,
|
||||||
onDeleteImage = { id -> viewModel.deletePage(id) },
|
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 -> {
|
is Screen.Main.Export -> {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.CreationExtras
|
import androidx.lifecycle.viewmodel.CreationExtras
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -87,7 +88,7 @@ class MainViewModel(
|
|||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.Eagerly,
|
started = SharingStarted.Eagerly,
|
||||||
initialValue = DocumentUiModel(emptyList(), ::getBitmap)
|
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
|
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() {
|
fun afterCaptureError() {
|
||||||
_captureState.value = CaptureState.Idle
|
_captureState.value = CaptureState.Idle
|
||||||
}
|
}
|
||||||
@@ -238,7 +244,7 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startNewDocument() {
|
fun startNewDocument() {
|
||||||
_pageIds.value = listOf()
|
_pageIds.value = persistentListOf()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
imageRepository.clear()
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.fairscan.app.CameraPermissionState
|
import org.fairscan.app.CameraPermissionState
|
||||||
import org.fairscan.app.LiveAnalysisState
|
import org.fairscan.app.LiveAnalysisState
|
||||||
@@ -157,6 +158,7 @@ fun CameraScreen(
|
|||||||
CommonPageListState(
|
CommonPageListState(
|
||||||
document = document,
|
document = document,
|
||||||
onPageClick = { index -> viewModel.navigateTo(Screen.Main.Document(index)) },
|
onPageClick = { index -> viewModel.navigateTo(Screen.Main.Document(index)) },
|
||||||
|
onPageReorder = { id, index -> viewModel.movePage(id, index) },
|
||||||
listState = listState,
|
listState = listState,
|
||||||
onLastItemPosition = { offset -> thumbnailCoords.value = offset },
|
onLastItemPosition = { offset -> thumbnailCoords.value = offset },
|
||||||
),
|
),
|
||||||
@@ -468,9 +470,12 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
|
|||||||
pageListState =
|
pageListState =
|
||||||
CommonPageListState(
|
CommonPageListState(
|
||||||
document = fakeDocument(
|
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),
|
LocalContext.current),
|
||||||
onPageClick = {},
|
onPageClick = {},
|
||||||
|
onPageReorder = { _,_ -> },
|
||||||
listState = LazyListState(),
|
listState = LazyListState(),
|
||||||
),
|
),
|
||||||
cameraUiState = CameraUiState(pageCount = 4, LiveAnalysisState(), captureState,
|
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.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import net.engawapg.lib.zoomable.ZoomState
|
import net.engawapg.lib.zoomable.ZoomState
|
||||||
import net.engawapg.lib.zoomable.zoomable
|
import net.engawapg.lib.zoomable.zoomable
|
||||||
import org.fairscan.app.Navigation
|
import org.fairscan.app.Navigation
|
||||||
@@ -66,6 +67,7 @@ fun DocumentScreen(
|
|||||||
navigation: Navigation,
|
navigation: Navigation,
|
||||||
onDeleteImage: (String) -> Unit,
|
onDeleteImage: (String) -> Unit,
|
||||||
onRotateImage: (String, Boolean) -> Unit,
|
onRotateImage: (String, Boolean) -> Unit,
|
||||||
|
onPageReorder: (String, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
// TODO Check how often images are loaded
|
// TODO Check how often images are loaded
|
||||||
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
|
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -81,7 +83,7 @@ fun DocumentScreen(
|
|||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
LaunchedEffect(currentPageIndex.intValue) {
|
LaunchedEffect(currentPageIndex.intValue) {
|
||||||
listState.animateScrollToItem(currentPageIndex.intValue)
|
listState.scrollToItem(currentPageIndex.intValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
MyScaffold(
|
MyScaffold(
|
||||||
@@ -89,6 +91,7 @@ fun DocumentScreen(
|
|||||||
pageListState = CommonPageListState(
|
pageListState = CommonPageListState(
|
||||||
document,
|
document,
|
||||||
onPageClick = { index -> currentPageIndex.intValue = index },
|
onPageClick = { index -> currentPageIndex.intValue = index },
|
||||||
|
onPageReorder = onPageReorder,
|
||||||
currentPageIndex = currentPageIndex.intValue,
|
currentPageIndex = currentPageIndex.intValue,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
),
|
),
|
||||||
@@ -226,12 +229,13 @@ fun DocumentScreenPreview() {
|
|||||||
FairScanTheme {
|
FairScanTheme {
|
||||||
DocumentScreen(
|
DocumentScreen(
|
||||||
fakeDocument(
|
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),
|
LocalContext.current),
|
||||||
initialPage = 1,
|
initialPage = 1,
|
||||||
navigation = dummyNavigation(),
|
navigation = dummyNavigation(),
|
||||||
onDeleteImage = { _ -> },
|
onDeleteImage = { _ -> },
|
||||||
onRotateImage = { _,_ -> },
|
onRotateImage = { _,_ -> },
|
||||||
|
onPageReorder = { _,_ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
package org.fairscan.app.view
|
package org.fairscan.app.view
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class DocumentUiModel(
|
data class DocumentUiModel(
|
||||||
private val pageIds: List<String>,
|
val pageIds: ImmutableList<String>,
|
||||||
private val imageLoader: (String) -> Bitmap?
|
private val imageLoader: (String) -> Bitmap?
|
||||||
) {
|
) {
|
||||||
fun pageCount(): Int {
|
fun pageCount(): Int {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import org.fairscan.app.CameraPermissionState
|
import org.fairscan.app.CameraPermissionState
|
||||||
import org.fairscan.app.Navigation
|
import org.fairscan.app.Navigation
|
||||||
import org.fairscan.app.R
|
import org.fairscan.app.R
|
||||||
@@ -284,7 +285,7 @@ fun HomeScreenPreviewWithCurrentDocument() {
|
|||||||
HomeScreen(
|
HomeScreen(
|
||||||
cameraPermission = rememberCameraPermissionState(),
|
cameraPermission = rememberCameraPermissionState(),
|
||||||
currentDocument = fakeDocument(
|
currentDocument = fakeDocument(
|
||||||
listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
persistentListOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||||
LocalContext.current),
|
LocalContext.current),
|
||||||
navigation = dummyNavigation(),
|
navigation = dummyNavigation(),
|
||||||
onClearScan = {},
|
onClearScan = {},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.fairscan.app.view
|
package org.fairscan.app.view
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.compose.foundation.Image
|
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.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
||||||
const val PAGE_LIST_ELEMENT_SIZE_DP = 120
|
const val PAGE_LIST_ELEMENT_SIZE_DP = 120
|
||||||
|
|
||||||
data class CommonPageListState(
|
data class CommonPageListState(
|
||||||
val document: DocumentUiModel,
|
val document: DocumentUiModel,
|
||||||
val onPageClick: (Int) -> Unit,
|
val onPageClick: (Int) -> Unit,
|
||||||
|
val onPageReorder: (String, Int) -> Unit,
|
||||||
val listState: LazyListState,
|
val listState: LazyListState,
|
||||||
val currentPageIndex: Int? = null,
|
val currentPageIndex: Int? = null,
|
||||||
val onLastItemPosition: ((Offset) -> Unit)? = null,
|
val onLastItemPosition: ((Offset) -> Unit)? = null,
|
||||||
@@ -64,12 +69,18 @@ fun CommonPageList(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
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 = {
|
val content: LazyListScope.() -> Unit = {
|
||||||
items(state.document.pageCount()) { index ->
|
itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item ->
|
||||||
// TODO Use small images rather than big ones
|
ReorderableItem(reorderableLazyListState, key = item) { _ ->
|
||||||
val image = state.document.load(index)
|
// TODO Use small images rather than big ones
|
||||||
if (image != null) {
|
val image = state.document.load(index)
|
||||||
PageThumbnail(image, index, state)
|
if (image != null) {
|
||||||
|
PageThumbnail(image, index, state, Modifier.draggableHandle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,33 +114,34 @@ private fun PageThumbnail(
|
|||||||
image: Bitmap,
|
image: Bitmap,
|
||||||
index: Int,
|
index: Int,
|
||||||
state: CommonPageListState,
|
state: CommonPageListState,
|
||||||
|
@SuppressLint("ModifierParameter") draggableModifier: Modifier,
|
||||||
) {
|
) {
|
||||||
val bitmap = image.asImageBitmap()
|
val bitmap = image.asImageBitmap()
|
||||||
val isSelected = index == state.currentPageIndex
|
val isSelected = index == state.currentPageIndex
|
||||||
val borderColor =
|
val borderColor =
|
||||||
if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent
|
if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent
|
||||||
val maxImageSize = PAGE_LIST_ELEMENT_SIZE_DP.dp
|
val maxImageSize = PAGE_LIST_ELEMENT_SIZE_DP.dp
|
||||||
var modifier =
|
var imageModifier =
|
||||||
if (bitmap.height > bitmap.width)
|
if (bitmap.height > bitmap.width)
|
||||||
Modifier.height(maxImageSize)
|
Modifier.height(maxImageSize)
|
||||||
else
|
else
|
||||||
Modifier.width(maxImageSize)
|
Modifier.width(maxImageSize)
|
||||||
if (index == state.document.lastIndex()) {
|
if (index == state.document.lastIndex()) {
|
||||||
val density = LocalDensity.current
|
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)) {
|
Box (modifier = Modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = modifier
|
modifier = imageModifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
.border(2.dp, borderColor)
|
.border(2.dp, borderColor)
|
||||||
.clickable { state.onPageClick(index) }
|
.clickable { state.onPageClick(index) }
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = draggableModifier
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
|
.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.content.Context
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import org.fairscan.app.Navigation
|
import org.fairscan.app.Navigation
|
||||||
|
|
||||||
fun dummyNavigation(): Navigation {
|
fun dummyNavigation(): Navigation {
|
||||||
@@ -23,10 +25,10 @@ fun dummyNavigation(): Navigation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun fakeDocument(): DocumentUiModel {
|
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 ->
|
return DocumentUiModel(pageIds) { id ->
|
||||||
context.assets.open(id).use { input ->
|
context.assets.open(id).use { input ->
|
||||||
BitmapFactory.decodeStream(input)
|
BitmapFactory.decodeStream(input)
|
||||||
|
|||||||
@@ -61,14 +61,21 @@ class ImageRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should find existing files at initialization`() {
|
fun `should find existing files at initialization with no json`() {
|
||||||
val bytes = byteArrayOf(101, 102, 103)
|
val scanDir = File(getFilesDir(), SCAN_DIR_NAME)
|
||||||
val repo1 = repo()
|
scanDir.mkdirs()
|
||||||
assertThat(repo1.imageIds()).isEmpty()
|
File(scanDir, "1.jpg").writeBytes(byteArrayOf(101, 102, 103))
|
||||||
repo1.add(bytes)
|
assertThat(repo().imageIds()).containsExactly("1.jpg")
|
||||||
val repo2 = repo()
|
}
|
||||||
assertThat(repo2.imageIds()).hasSize(1)
|
|
||||||
assertThat(repo2.getContent(repo2.imageIds()[0])).isEqualTo(bytes)
|
@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
|
@Test
|
||||||
@@ -117,4 +124,18 @@ class ImageRepositoryTest {
|
|||||||
val id5 = repo.imageIds().last()
|
val id5 = repo.imageIds().last()
|
||||||
assertThat(id5).isEqualTo("$baseId-270.jpg")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ zoomable = "2.8.1"
|
|||||||
aboutLibraries = "12.2.4"
|
aboutLibraries = "12.2.4"
|
||||||
protobuf = "0.9.5"
|
protobuf = "0.9.5"
|
||||||
protobufJavaLite = "4.32.0"
|
protobufJavaLite = "4.32.0"
|
||||||
|
kotlinSerialization = "1.9.0"
|
||||||
|
reorderable = "3.0.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
opencv = { group="org.opencv", name="opencv", version.ref = "opencv" }
|
||||||
pdfbox = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" }
|
pdfbox = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" }
|
||||||
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
|
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" }
|
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" }
|
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" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" }
|
license = { id = "com.github.hierynomus.license", version.ref = "license" }
|
||||||
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
|
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
|
||||||
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
|
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user