Refactor UI to remove blocking image loading and support suspend APIs
This commit is contained in:
@@ -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 -> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = { _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user