Refactoring: introduce DocumentUiModel (#13)

* Refactoring: introduce DocumentUiModel

* Make DocumentUiModel.imageLoader private

* Make DocumentUiModel.pageIds private

* Expose DocumentUiModel in MainViewModel

* Rename variables named documentUiModel
This commit is contained in:
pynicolas
2025-08-20 17:34:47 +02:00
committed by GitHub
parent d78115baaa
commit e74bbcd0d6
6 changed files with 102 additions and 62 deletions

View File

@@ -58,7 +58,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle() val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle()
val pageIds by viewModel.pageIds.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
MyScanTheme { MyScanTheme {
val navigation = Navigation( val navigation = Navigation(
toCameraScreen = { viewModel.navigateTo(Screen.Camera) }, toCameraScreen = { viewModel.navigateTo(Screen.Camera) },
@@ -78,9 +78,8 @@ class MainActivity : ComponentActivity() {
} }
is Screen.Document -> { is Screen.Document -> {
DocumentScreen ( DocumentScreen (
pageIds, document = document,
initialPage = screen.initialPage, initialPage = screen.initialPage,
imageLoader = { id -> viewModel.getBitmap(id) },
navigation = navigation, navigation = navigation,
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(
startGeneration = viewModel::startPdfGeneration, startGeneration = viewModel::startPdfGeneration,

View File

@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.mydomain.myscan.ui.PdfGenerationUiState import org.mydomain.myscan.ui.PdfGenerationUiState
import org.mydomain.myscan.view.DocumentUiModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
@@ -71,8 +72,18 @@ class MainViewModel(
val currentScreen: StateFlow<Screen> = _screenStack.map { it.last() } val currentScreen: StateFlow<Screen> = _screenStack.map { it.last() }
.stateIn(viewModelScope, SharingStarted.Eagerly, Screen.Camera) .stateIn(viewModelScope, SharingStarted.Eagerly, Screen.Camera)
private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds()) private val _pageIds = MutableStateFlow(imageRepository.imageIds())
val pageIds: StateFlow<List<String>> = _pageIds val documentUiModel: StateFlow<DocumentUiModel> =
_pageIds.map { ids ->
DocumentUiModel(
pageIds = ids,
imageLoader = ::getBitmap
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = DocumentUiModel(emptyList(), ::getBitmap)
)
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle) private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
val captureState: StateFlow<CaptureState> = _captureState val captureState: StateFlow<CaptureState> = _captureState

View File

@@ -101,7 +101,7 @@ fun CameraScreen(
onFinalizePressed: () -> Unit, onFinalizePressed: () -> Unit,
) { ) {
var previewView by remember { mutableStateOf<PreviewView?>(null) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
val pageIds by viewModel.pageIds.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) } val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
var isDebugMode by remember { mutableStateOf(false) } var isDebugMode by remember { mutableStateOf(false) }
@@ -129,9 +129,9 @@ fun CameraScreen(
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
LaunchedEffect(pageIds.size) { LaunchedEffect(document.pageCount()) {
if (pageIds.isNotEmpty()) { if (!document.isEmpty()) {
listState.animateScrollToItem(pageIds.lastIndex) listState.animateScrollToItem(document.lastIndex())
} }
} }
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -145,14 +145,13 @@ fun CameraScreen(
}, },
pageListState = pageListState =
CommonPageListState( CommonPageListState(
pageIds = pageIds, document = document,
imageLoader = { id -> viewModel.getBitmap(id) },
onPageClick = { index -> viewModel.navigateTo(Screen.Document(index)) }, onPageClick = { index -> viewModel.navigateTo(Screen.Document(index)) },
listState = listState, listState = listState,
onLastItemPosition = { offset -> thumbnailCoords.value = offset }, onLastItemPosition = { offset -> thumbnailCoords.value = offset },
), ),
cameraUiState = CameraUiState( cameraUiState = CameraUiState(
pageIds.size, document.pageCount(),
liveAnalysisState, liveAnalysisState,
captureState, captureState,
showDetectionError, showDetectionError,
@@ -457,12 +456,13 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
}, },
pageListState = pageListState =
CommonPageListState( CommonPageListState(
pageIds = listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }, document = DocumentUiModel(
imageLoader = { id -> pageIds = listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
context.assets.open(id).use { input -> imageLoader = { id ->
BitmapFactory.decodeStream(input) context.assets.open(id).use { input ->
} BitmapFactory.decodeStream(input)
}, }
}),
onPageClick = {}, onPageClick = {},
listState = LazyListState(), listState = LazyListState(),
), ),

View File

@@ -14,7 +14,6 @@
*/ */
package org.mydomain.myscan.view package org.mydomain.myscan.view
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -68,9 +67,8 @@ import org.mydomain.myscan.ui.theme.MyScanTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DocumentScreen( fun DocumentScreen(
pageIds: List<String>, document: DocumentUiModel,
initialPage: Int, initialPage: Int,
imageLoader: (String) -> Bitmap?,
navigation: Navigation, navigation: Navigation,
pdfActions: PdfGenerationActions, pdfActions: PdfGenerationActions,
onStartNew: () -> Unit, onStartNew: () -> Unit,
@@ -80,8 +78,8 @@ fun DocumentScreen(
val showNewDocDialog = rememberSaveable { mutableStateOf(false) } val showNewDocDialog = rememberSaveable { mutableStateOf(false) }
val showPdfDialog = rememberSaveable { mutableStateOf(false) } val showPdfDialog = rememberSaveable { mutableStateOf(false) }
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) } val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
if (currentPageIndex.intValue >= pageIds.size) { if (currentPageIndex.intValue >= document.pageCount()) {
currentPageIndex.intValue = pageIds.size - 1 currentPageIndex.intValue = document.pageCount() - 1
} }
if (currentPageIndex.intValue < 0) { if (currentPageIndex.intValue < 0) {
navigation.toCameraScreen() navigation.toCameraScreen()
@@ -97,8 +95,7 @@ fun DocumentScreen(
MyScaffold( MyScaffold(
toAboutScreen = navigation.toAboutScreen, toAboutScreen = navigation.toAboutScreen,
pageListState = CommonPageListState( pageListState = CommonPageListState(
pageIds, document,
imageLoader,
onPageClick = { index -> currentPageIndex.intValue = index }, onPageClick = { index -> currentPageIndex.intValue = index },
currentPageIndex = currentPageIndex.intValue, currentPageIndex = currentPageIndex.intValue,
listState = listState, listState = listState,
@@ -115,7 +112,7 @@ fun DocumentScreen(
) )
}, },
) { modifier -> ) { modifier ->
DocumentPreview(pageIds, imageLoader, currentPageIndex, onDeleteImage, modifier) DocumentPreview(document, currentPageIndex, onDeleteImage, modifier)
if (showNewDocDialog.value) { if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog) NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog)
} }
@@ -130,13 +127,12 @@ fun DocumentScreen(
@Composable @Composable
private fun DocumentPreview( private fun DocumentPreview(
pageIds: List<String>, document: DocumentUiModel,
imageLoader: (String) -> Bitmap?,
currentPageIndex: MutableIntState, currentPageIndex: MutableIntState,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
val imageId = pageIds[currentPageIndex.intValue] val imageId = document.pageId(currentPageIndex.intValue)
Column ( Column (
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
@@ -144,7 +140,7 @@ private fun DocumentPreview(
Box ( Box (
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
val bitmap = imageLoader(imageId) val bitmap = document.load(currentPageIndex.intValue)
if (bitmap != null) { if (bitmap != null) {
val imageBitmap = bitmap.asImageBitmap() val imageBitmap = bitmap.asImageBitmap()
val zoomState = rememberZoomState( val zoomState = rememberZoomState(
@@ -175,7 +171,7 @@ private fun DocumentPreview(
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(8.dp) .padding(8.dp)
) )
Text("${currentPageIndex.intValue + 1} / ${pageIds.size}", Text("${currentPageIndex.intValue + 1} / ${document.pageCount()}",
color = MaterialTheme.colorScheme.inverseOnSurface, color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
@@ -243,13 +239,15 @@ fun DocumentScreenPreview() {
val context = LocalContext.current val context = LocalContext.current
MyScanTheme { MyScanTheme {
DocumentScreen( DocumentScreen(
pageIds = listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }, DocumentUiModel(
initialPage = 1, listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
imageLoader = { id -> { id ->
context.assets.open(id).use { input -> context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input) BitmapFactory.decodeStream(input)
}
} }
}, ),
initialPage = 1,
navigation = Navigation( navigation = Navigation(
{}, {}, {}, {}, {}), {}, {}, {}, {}, {}),
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(

View File

@@ -0,0 +1,38 @@
/*
* 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.mydomain.myscan.view
import android.graphics.Bitmap
data class DocumentUiModel(
private val pageIds: List<String>,
private val imageLoader: (String) -> Bitmap?
) {
fun pageCount(): Int {
return pageIds.size
}
fun pageId(index: Int): String {
return pageIds[index]
}
fun isEmpty(): Boolean {
return pageIds.isEmpty()
}
fun lastIndex(): Int {
return pageIds.lastIndex
}
fun load(index: Int): Bitmap? {
return imageLoader(pageIds[index])
}
}

View File

@@ -28,9 +28,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
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
@@ -51,8 +51,7 @@ import androidx.compose.ui.unit.sp
const val PAGE_LIST_ELEMENT_SIZE_DP = 120 const val PAGE_LIST_ELEMENT_SIZE_DP = 120
data class CommonPageListState( data class CommonPageListState(
val pageIds: List<String>, val document: DocumentUiModel,
val imageLoader: (String) -> Bitmap?,
val onPageClick: (Int) -> Unit, val onPageClick: (Int) -> Unit,
val listState: LazyListState, val listState: LazyListState,
val currentPageIndex: Int? = null, val currentPageIndex: Int? = null,
@@ -65,37 +64,32 @@ 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 content: LazyListScope.() -> Unit = {
items(state.document.pageCount()) { index ->
// TODO Use small images rather than big ones
val image = state.document.load(index)
if (image != null) {
PageThumbnail(image, index, state)
}
}
}
if (isLandscape) { if (isLandscape) {
LazyColumn ( LazyColumn (
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier,
) { content = content,
itemsIndexed(state.pageIds) { index, id -> )
// TODO Use small images rather than big ones
val image = state.imageLoader(id)
if (image != null) {
PageThumbnail(image, index, state)
}
}
}
} else { } else {
LazyRow ( LazyRow (
state = state.listState, state = state.listState,
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer), modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { content = content,
itemsIndexed(state.pageIds) { index, id -> )
// TODO Use small images rather than big ones
val image = state.imageLoader(id)
if (image != null) {
PageThumbnail(image, index, state)
}
}
}
} }
if (state.pageIds.isEmpty()) { if (state.document.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.height(120.dp) .height(120.dp)
@@ -120,7 +114,7 @@ private fun PageThumbnail(
Modifier.height(maxImageSize) Modifier.height(maxImageSize)
else else
Modifier.width(maxImageSize) Modifier.width(maxImageSize)
if (index == state.pageIds.lastIndex) { if (index == state.document.lastIndex()) {
val density = LocalDensity.current val density = LocalDensity.current
modifier = modifier.addPositionCallback(state.onLastItemPosition, density, 1.0f) modifier = modifier.addPositionCallback(state.onLastItemPosition, density, 1.0f)
} }