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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@
*/
package org.mydomain.myscan.view
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
@@ -68,9 +67,8 @@ import org.mydomain.myscan.ui.theme.MyScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentScreen(
pageIds: List<String>,
document: DocumentUiModel,
initialPage: Int,
imageLoader: (String) -> Bitmap?,
navigation: Navigation,
pdfActions: PdfGenerationActions,
onStartNew: () -> Unit,
@@ -80,8 +78,8 @@ fun DocumentScreen(
val showNewDocDialog = rememberSaveable { mutableStateOf(false) }
val showPdfDialog = rememberSaveable { mutableStateOf(false) }
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
if (currentPageIndex.intValue >= pageIds.size) {
currentPageIndex.intValue = pageIds.size - 1
if (currentPageIndex.intValue >= document.pageCount()) {
currentPageIndex.intValue = document.pageCount() - 1
}
if (currentPageIndex.intValue < 0) {
navigation.toCameraScreen()
@@ -97,8 +95,7 @@ fun DocumentScreen(
MyScaffold(
toAboutScreen = navigation.toAboutScreen,
pageListState = CommonPageListState(
pageIds,
imageLoader,
document,
onPageClick = { index -> currentPageIndex.intValue = index },
currentPageIndex = currentPageIndex.intValue,
listState = listState,
@@ -115,7 +112,7 @@ fun DocumentScreen(
)
},
) { modifier ->
DocumentPreview(pageIds, imageLoader, currentPageIndex, onDeleteImage, modifier)
DocumentPreview(document, currentPageIndex, onDeleteImage, modifier)
if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog)
}
@@ -130,13 +127,12 @@ fun DocumentScreen(
@Composable
private fun DocumentPreview(
pageIds: List<String>,
imageLoader: (String) -> Bitmap?,
document: DocumentUiModel,
currentPageIndex: MutableIntState,
onDeleteImage: (String) -> Unit,
modifier: Modifier,
) {
val imageId = pageIds[currentPageIndex.intValue]
val imageId = document.pageId(currentPageIndex.intValue)
Column (
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceContainerLow)
@@ -144,7 +140,7 @@ private fun DocumentPreview(
Box (
modifier = Modifier.fillMaxSize()
) {
val bitmap = imageLoader(imageId)
val bitmap = document.load(currentPageIndex.intValue)
if (bitmap != null) {
val imageBitmap = bitmap.asImageBitmap()
val zoomState = rememberZoomState(
@@ -175,7 +171,7 @@ private fun DocumentPreview(
.align(Alignment.BottomEnd)
.padding(8.dp)
)
Text("${currentPageIndex.intValue + 1} / ${pageIds.size}",
Text("${currentPageIndex.intValue + 1} / ${document.pageCount()}",
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.BottomStart)
@@ -243,13 +239,15 @@ fun DocumentScreenPreview() {
val context = LocalContext.current
MyScanTheme {
DocumentScreen(
pageIds = listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
initialPage = 1,
imageLoader = { id ->
DocumentUiModel(
listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
{ id ->
context.assets.open(id).use { input ->
BitmapFactory.decodeStream(input)
}
},
}
),
initialPage = 1,
navigation = Navigation(
{}, {}, {}, {}, {}),
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -51,8 +51,7 @@ import androidx.compose.ui.unit.sp
const val PAGE_LIST_ELEMENT_SIZE_DP = 120
data class CommonPageListState(
val pageIds: List<String>,
val imageLoader: (String) -> Bitmap?,
val document: DocumentUiModel,
val onPageClick: (Int) -> Unit,
val listState: LazyListState,
val currentPageIndex: Int? = null,
@@ -65,37 +64,32 @@ fun CommonPageList(
modifier: Modifier = Modifier,
) {
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
if (isLandscape) {
LazyColumn (
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
itemsIndexed(state.pageIds) { index, id ->
val content: LazyListScope.() -> Unit = {
items(state.document.pageCount()) { index ->
// TODO Use small images rather than big ones
val image = state.imageLoader(id)
val image = state.document.load(index)
if (image != null) {
PageThumbnail(image, index, state)
}
}
}
if (isLandscape) {
LazyColumn (
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
content = content,
)
} else {
LazyRow (
state = state.listState,
contentPadding = PaddingValues(4.dp),
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
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)
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
}
}
}
if (state.pageIds.isEmpty()) {
if (state.document.isEmpty()) {
Box(
modifier = Modifier
.height(120.dp)
@@ -120,7 +114,7 @@ private fun PageThumbnail(
Modifier.height(maxImageSize)
else
Modifier.width(maxImageSize)
if (index == state.pageIds.lastIndex) {
if (index == state.document.lastIndex()) {
val density = LocalDensity.current
modifier = modifier.addPositionCallback(state.onLastItemPosition, density, 1.0f)
}