Refactor UI to remove blocking image loading and support suspend APIs

This commit is contained in:
Pierre-Yves Nicolas
2026-03-27 18:25:34 +01:00
parent 446b915d59
commit 05a8af577c
9 changed files with 132 additions and 79 deletions

View File

@@ -53,7 +53,7 @@ import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.screens.DocumentScreen
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.about.AboutEvent
import org.fairscan.app.ui.screens.about.AboutScreen
@@ -62,6 +62,7 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.document.DocumentUiState
import org.fairscan.app.ui.screens.export.ExportActions
import org.fairscan.app.ui.screens.export.ExportEvent
import org.fairscan.app.ui.screens.export.ExportResult
@@ -123,6 +124,8 @@ class MainActivity : ComponentActivity() {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val currentPageIndex by viewModel.currentPageIndex.collectAsStateWithLifecycle()
val currentPageBitmap by viewModel.currentPageBitmap.collectAsStateWithLifecycle()
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState()
CollectCameraEvents(cameraViewModel, viewModel)
@@ -152,7 +155,7 @@ class MainActivity : ComponentActivity() {
navigation.toExportScreen
}
when (val screen = currentScreen) {
when (currentScreen) {
is Screen.Main.Home -> {
val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle()
HomeScreen(
@@ -177,13 +180,13 @@ class MainActivity : ComponentActivity() {
}
is Screen.Main.Document -> {
DocumentScreen (
document = document,
initialPage = screen.initialPage,
uiState = DocumentUiState(currentPageIndex, currentPageBitmap, document),
navigation = navigation,
onExportClick = onExportClick,
onDeleteImage = { id -> viewModel.deletePage(id) },
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) },
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
onPageSelected = viewModel::onPageSelected
)
}
is Screen.Main.Export -> {

View File

@@ -21,21 +21,26 @@ import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.ScanPage
import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PageThumbnail
import kotlin.math.min
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
@@ -53,18 +58,40 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
val documentUiModel: StateFlow<DocumentUiModel> =
_pages.map { pages ->
DocumentUiModel(
pageKeys = pages.map { it.key() }.toImmutableList(),
imageLoader = ::getBitmap,
thumbnailLoader = ::getThumbnail,
)
}.stateIn(
pages.map {
val jpeg = imageRepository.getThumbnail(it.key())
PageThumbnail(it.key(), jpeg?.toBitmap())
}.toImmutableList()
}
.flowOn(Dispatchers.IO)
.map { DocumentUiModel(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail)
initialValue = DocumentUiModel(persistentListOf())
)
private val _currentPageIndex = MutableStateFlow(0)
val currentPageIndex: StateFlow<Int> =
_currentPageIndex.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
@OptIn(ExperimentalCoroutinesApi::class)
val currentPageBitmap: StateFlow<Bitmap?> =
_currentPageIndex
.combine(_pages) { index, pages -> pages.getOrNull(index) }
.mapLatest { page ->
page?.let { imageRepository.jpegBytes(it.key())?.toBitmap() }
}
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun onPageSelected(index: Int) {
_currentPageIndex.value = index
}
fun navigateTo(destination: Screen) {
if (destination is Screen.Main.Document) {
_currentPageIndex.value = min(_pages.value.size - 1, destination.initialPage)
}
_navigationState.update { it.navigateTo(destination) }
}
@@ -99,6 +126,13 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
imageRepository.pages()
}
_pages.value = pages
if (pages.isEmpty()) {
navigateTo(Screen.Main.Camera)
_currentPageIndex.value = 0
} else if (_currentPageIndex.value >= pages.size) {
_currentPageIndex.value = pages.size - 1
}
}
}
@@ -111,15 +145,8 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
}
}
fun getBitmap(key: PageViewKey): Bitmap? {
val bytes = imageRepository.jpegBytes(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
fun getThumbnail(key: PageViewKey): Bitmap? {
val bytes = imageRepository.getThumbnail(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
private fun ByteArray.toBitmap() : Bitmap =
BitmapFactory.decodeByteArray(this, 0, this.size)
fun handleImageCaptured(capturedPage: CapturedPage) {
viewModelScope.launch {

View File

@@ -15,6 +15,7 @@
package org.fairscan.app.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -22,21 +23,25 @@ import kotlinx.collections.immutable.toImmutableList
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PageThumbnail
fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
}
fun fakeDocument(): DocumentUiModel {
return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null })
return DocumentUiModel(persistentListOf())
}
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
val loader = { key: PageViewKey ->
context.assets.open("${key.pageId}.jpg").use { input ->
BitmapFactory.decodeStream(input)
}
}
val pageKeys = pageIds.map { PageViewKey(it, Rotation.R0) }.toImmutableList()
return DocumentUiModel(pageKeys, loader, loader)
val pageKeys = pageIds.map {
PageThumbnail(PageViewKey(it, Rotation.R0), fakeImage(it, context))
}.toImmutableList()
return DocumentUiModel(pageKeys)
}
fun fakeImage(id: String, context: Context): Bitmap =
context.assets.open("${id}.jpg").use { input ->
BitmapFactory.decodeStream(input)
}

View File

@@ -79,11 +79,11 @@ fun CommonPageList(
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val reorderableLazyListState = rememberReorderableLazyListState(state.listState) { from, to ->
val pageId = state.document.pageKeys[from.index].pageId
val pageId = state.document.pages[from.index].key.pageId
state.onPageReorder(pageId, to.index)
}
val content: LazyListScope.() -> Unit = {
itemsIndexed(state.document.pageKeys, key = { _, item -> item.saveKey}) { index, item ->
itemsIndexed(state.document.pages.map { it.key }, key = { _, item -> item.saveKey}) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.saveKey) { isDragging ->
val borderColor =
if (isDragging) MaterialTheme.colorScheme.primary else Color.Transparent
@@ -94,7 +94,7 @@ fun CommonPageList(
color = borderColor,
shape = RoundedCornerShape(6.dp)
)
val image = state.document.loadThumbnail(index)
val image = state.document.thumbnail(index)
if (image != null) {
PageThumbnail(image, index, state, modifier)
}

View File

@@ -12,7 +12,7 @@
* 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.ui.screens
package org.fairscan.app.ui.screens.document
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
@@ -45,8 +45,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -74,44 +72,38 @@ import org.fairscan.app.ui.components.MyScaffold
import org.fairscan.app.ui.components.SecondaryActionButton
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.fakeImage
import org.fairscan.app.ui.theme.FairScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentScreen(
document: DocumentUiModel,
initialPage: Int,
uiState: DocumentUiState,
navigation: Navigation,
onExportClick: () -> Unit,
onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit,
onPageReorder: (String, Int) -> Unit,
onPageSelected: (Int) -> Unit,
) {
// TODO Check how often images are loaded
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
if (currentPageIndex.intValue >= document.pageCount()) {
currentPageIndex.intValue = document.pageCount() - 1
}
if (currentPageIndex.intValue < 0) {
navigation.toCameraScreen()
return
}
val document = uiState.document
val currentPageIndex = uiState.currentPageIndex
BackHandler { navigation.back() }
val listState = rememberLazyListState()
LaunchedEffect(initialPage) {
listState.scrollToItem(initialPage)
LaunchedEffect(currentPageIndex) {
listState.scrollToItem(currentPageIndex)
}
MyScaffold(
navigation = navigation,
pageListState = CommonPageListState(
document,
onPageClick = { index -> currentPageIndex.intValue = index },
onPageClick = { index -> onPageSelected(index) },
onPageReorder = onPageReorder,
currentPageIndex = currentPageIndex.intValue,
currentPageIndex = currentPageIndex,
listState = listState,
showPageNumbers = true,
),
@@ -121,8 +113,7 @@ fun DocumentScreen(
},
) { modifier ->
DocumentPreview(
document,
currentPageIndex,
uiState,
{ showDeletePageDialog.value = true },
onRotateImage,
modifier
@@ -132,20 +123,21 @@ fun DocumentScreen(
title = stringResource(R.string.delete_page),
message = stringResource(R.string.delete_page_warning),
showDialog = showDeletePageDialog
) { onDeleteImage(document.pageId(currentPageIndex.intValue)) }
) { onDeleteImage(document.pageId(currentPageIndex)) }
}
}
}
@Composable
private fun DocumentPreview(
document: DocumentUiModel,
currentPageIndex: MutableIntState,
uiState: DocumentUiState,
onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit,
modifier: Modifier,
) {
val imageId = document.pageId(currentPageIndex.intValue)
val currentPageIndex = uiState.currentPageIndex
val document = uiState.document
val imageId = document.pageId(currentPageIndex)
Column (
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceContainerLow)
@@ -153,7 +145,7 @@ private fun DocumentPreview(
Box (
modifier = Modifier.fillMaxSize()
) {
val bitmap = document.load(currentPageIndex.intValue)
val bitmap = uiState.currentPageBitmap
if (bitmap != null) {
val imageBitmap = bitmap.asImageBitmap()
val zoomState = remember(imageId) {
@@ -184,7 +176,7 @@ private fun DocumentPreview(
.align(Alignment.BottomEnd)
.padding(8.dp)
)
Text("${currentPageIndex.intValue + 1} / ${document.pageCount()}",
Text("${currentPageIndex + 1} / ${document.pageCount()}",
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.BottomStart)
@@ -266,17 +258,19 @@ private fun BottomBar(
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
fun DocumentScreenPreview() {
FairScanTheme {
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current)
val document = fakeDocument(
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
LocalContext.current
)
DocumentScreen(
fakeDocument(
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
LocalContext.current
),
initialPage = 1,
uiState = DocumentUiState(1, image, document),
navigation = dummyNavigation(),
onExportClick = {},
onDeleteImage = { _ -> },
onRotateImage = { _,_ -> },
onPageReorder = { _,_ -> },
onPageSelected = { _ -> },
)
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025-2026 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.ui.screens.document
import android.graphics.Bitmap
import org.fairscan.app.ui.state.DocumentUiModel
data class DocumentUiState(
val currentPageIndex: Int,
val currentPageBitmap: Bitmap?,
val document: DocumentUiModel,
)

View File

@@ -259,7 +259,7 @@ private fun PdfInfos(
val result = uiState.result
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val thumbnail = currentDocument.loadThumbnail(0)
val thumbnail = currentDocument.thumbnail(0)
thumbnail?.let {
Card(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),

View File

@@ -182,7 +182,7 @@ fun OngoingScanBanner(
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
currentDocument.load(0)?.let {
currentDocument.thumbnail(0)?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,

View File

@@ -19,26 +19,26 @@ import kotlinx.collections.immutable.ImmutableList
import org.fairscan.app.domain.PageViewKey
data class DocumentUiModel(
val pageKeys: ImmutableList<PageViewKey>,
private val imageLoader: (PageViewKey) -> Bitmap?,
private val thumbnailLoader: (PageViewKey) -> Bitmap?
val pages: ImmutableList<PageThumbnail>,
) {
fun pageCount(): Int {
return pageKeys.size
return pages.size
}
fun pageId(index: Int): String {
return pageKeys[index].pageId
return pages[index].key.pageId
}
fun isEmpty(): Boolean {
return pageKeys.isEmpty()
return pages.isEmpty()
}
fun lastIndex(): Int {
return pageKeys.lastIndex
return pages.lastIndex
}
fun load(index: Int): Bitmap? {
return imageLoader(pageKeys[index])
fun thumbnail(index: Int): Bitmap? {
return pages[index].thumbnail
}
fun loadThumbnail(index: Int): Bitmap? {
return thumbnailLoader(pageKeys[index])
}
}
}
data class PageThumbnail(
val key: PageViewKey,
val thumbnail: Bitmap?,
)