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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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 ->
ReorderableItem(reorderableLazyListState, key = item) { _ ->
// TODO Use small images rather than big ones // TODO Use small images rather than big ones
val image = state.document.load(index) val image = state.document.load(index)
if (image != null) { if (image != null) {
PageThumbnail(image, index, state) 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))

View File

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

View File

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

View File

@@ -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" }