From 05a8af577c2396d16107a6eb170ddfb29b8166b5 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:25:34 +0100 Subject: [PATCH] Refactor UI to remove blocking image loading and support suspend APIs --- .../java/org/fairscan/app/MainActivity.kt | 11 ++-- .../java/org/fairscan/app/MainViewModel.kt | 61 +++++++++++++------ .../java/org/fairscan/app/ui/PreviewUtils.kt | 21 ++++--- .../fairscan/app/ui/components/PageList.kt | 6 +- .../screens/{ => document}/DocumentScreen.kt | 58 ++++++++---------- .../ui/screens/document/DocumentUiState.kt | 24 ++++++++ .../app/ui/screens/export/ExportScreen.kt | 2 +- .../app/ui/screens/home/HomeScreen.kt | 2 +- .../fairscan/app/ui/state/DocumentUiModel.kt | 26 ++++---- 9 files changed, 132 insertions(+), 79 deletions(-) rename app/src/main/java/org/fairscan/app/ui/screens/{ => document}/DocumentScreen.kt (87%) create mode 100644 app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index d1f87b1..430eede 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -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 -> { diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 687c202..8178717 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -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 = _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 = + _currentPageIndex.stateIn(viewModelScope, SharingStarted.Eagerly, 0) + @OptIn(ExperimentalCoroutinesApi::class) + val currentPageBitmap: StateFlow = + _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 { diff --git a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt index 592f443..68632a0 100644 --- a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt @@ -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, 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) + } + diff --git a/app/src/main/java/org/fairscan/app/ui/components/PageList.kt b/app/src/main/java/org/fairscan/app/ui/components/PageList.kt index aaf6669..3697d75 100644 --- a/app/src/main/java/org/fairscan/app/ui/components/PageList.kt +++ b/app/src/main/java/org/fairscan/app/ui/components/PageList.kt @@ -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) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt similarity index 87% rename from app/src/main/java/org/fairscan/app/ui/screens/DocumentScreen.kt rename to app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index e29d92f..ee22811 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -12,7 +12,7 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -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 = { _ -> }, ) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt new file mode 100644 index 0000000..95a5fb4 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentUiState.kt @@ -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 . + */ +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, +) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index 77d9c93..372c9d2 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -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), diff --git a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/home/HomeScreen.kt index e8cf24c..e7ab3af 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/home/HomeScreen.kt @@ -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, diff --git a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt index 5687d7b..ae25fc7 100644 --- a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt @@ -19,26 +19,26 @@ import kotlinx.collections.immutable.ImmutableList import org.fairscan.app.domain.PageViewKey data class DocumentUiModel( - val pageKeys: ImmutableList, - private val imageLoader: (PageViewKey) -> Bitmap?, - private val thumbnailLoader: (PageViewKey) -> Bitmap? + val pages: ImmutableList, ) { 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]) - } -} \ No newline at end of file +} + +data class PageThumbnail( + val key: PageViewKey, + val thumbnail: Bitmap?, +)