diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3f7b3c7c..f1b1195e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,7 +3,6 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.aboutLibrariesAndroid) - alias(libs.plugins.protobuf) alias(libs.plugins.kotlin.serialization) } @@ -122,10 +121,8 @@ dependencies { implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) - implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.documentfile) - implementation(libs.protobuf.javalite) implementation(libs.litert) implementation(libs.litert.support) implementation(libs.litert.metadata) @@ -160,21 +157,6 @@ aboutLibraries { } } -protobuf { - protoc { - artifact = "com.google.protobuf:protoc:4.32.0" - } - generateProtoTasks { - all().forEach { task -> - task.builtins { - create("java") { - option("lite") - } - } - } - } -} - // See https://developer.android.com/build/configure-apk-splits androidComponents { onVariants { variant -> diff --git a/app/src/main/java/org/fairscan/app/FairScanApp.kt b/app/src/main/java/org/fairscan/app/FairScanApp.kt index 2db3aa13..a974bcd1 100644 --- a/app/src/main/java/org/fairscan/app/FairScanApp.kt +++ b/app/src/main/java/org/fairscan/app/FairScanApp.kt @@ -23,12 +23,10 @@ import androidx.lifecycle.viewmodel.CreationExtras import org.fairscan.app.data.FileLogger import org.fairscan.app.data.FileManager import org.fairscan.app.data.LogRepository -import org.fairscan.app.data.recentDocumentsDataStore import org.fairscan.app.domain.ImageSegmentationService import org.fairscan.app.platform.AndroidImageLoader import org.fairscan.app.platform.AndroidPdfWriter import org.fairscan.app.ui.screens.camera.CameraViewModel -import org.fairscan.app.ui.screens.home.HomeViewModel import org.fairscan.app.ui.screens.settings.SettingsRepository import org.fairscan.app.ui.screens.settings.SettingsViewModel import java.io.File @@ -57,7 +55,6 @@ class AppContainer(context: Context) { val logger = FileLogger(logRepository) val imageSegmentationService = ImageSegmentationService(context, logger) val imageLoader = AndroidImageLoader(context.contentResolver) - val recentDocumentsDataStore = context.recentDocumentsDataStore val settingsRepository = SettingsRepository(context) @Suppress("UNCHECKED_CAST") @@ -69,7 +66,6 @@ class AppContainer(context: Context) { } } - val homeViewModelFactory = viewModelFactory { HomeViewModel(it, context) } val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) } val settingsViewModelFactory = viewModelFactory { SettingsViewModel(it) } diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index ba81dbff..f36806f9 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -54,7 +54,6 @@ 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.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 @@ -63,14 +62,12 @@ 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.DocumentScreen import org.fairscan.app.ui.screens.export.ExportActions import org.fairscan.app.ui.screens.export.ExportEvent import org.fairscan.app.ui.screens.export.ExportResult import org.fairscan.app.ui.screens.export.ExportScreenWrapper import org.fairscan.app.ui.screens.export.ExportViewModel -import org.fairscan.app.ui.screens.home.HomeScreen -import org.fairscan.app.ui.screens.home.HomeViewModel -import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.SettingsScreen import org.fairscan.app.ui.screens.settings.SettingsViewModel import org.fairscan.app.ui.theme.FairScanTheme @@ -97,7 +94,7 @@ class MainActivity : ComponentActivity() { val imageRepository = sessionViewModel.imageRepository val viewModel: MainViewModel by viewModels { appContainer.viewModelFactory { - MainViewModel(imageRepository, launchMode) + MainViewModel(imageRepository) } } val exportViewModel: ExportViewModel by viewModels { @@ -110,7 +107,6 @@ class MainActivity : ComponentActivity() { AboutViewModel(appContainer, imageRepository) } } - val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory } val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory } val settingsViewModel: SettingsViewModel @@ -157,16 +153,8 @@ class MainActivity : ComponentActivity() { } when (currentScreen) { - is Screen.Main.Home -> { - val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle() - HomeScreen( - cameraPermission = cameraPermission, - currentDocument = document, - navigation = navigation, - onClearScan = { viewModel.startNewDocument() }, - recentDocuments = recentDocs, - onOpenPdf = { fileUri -> openUri(fileUri, ExportFormat.PDF.mimeType, logger) } - ) + null -> { + // waiting to load pages to get an initial screen } is Screen.Main.Camera -> { val pickMultiple = rememberLauncherForActivityResult( @@ -216,7 +204,7 @@ class MainActivity : ComponentActivity() { onCloseScan = { exportViewModel.resetFilename() viewModel.startNewDocument() - viewModel.navigateTo(Screen.Main.Home) + viewModel.navigateTo(Screen.Main.Camera) } ) } @@ -468,7 +456,6 @@ class MainActivity : ComponentActivity() { } private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation( - toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) }, toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) }, toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) }, toExportScreen = { viewModel.navigateTo(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 f7d05166..fc8a14d0 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -45,17 +45,27 @@ import org.fairscan.imageprocessing.ColorMode import kotlin.math.min @OptIn(ExperimentalCoroutinesApi::class) -class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() { +class MainViewModel(val imageRepository: ImageRepository): ViewModel() { - private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode)) - val currentScreen: StateFlow = _navigationState.map { it.current } - .stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current) + private val _navigationState = MutableStateFlow(null) + val currentScreen: StateFlow = _navigationState.map { it?.current } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _pages = MutableStateFlow>(emptyList()) + init { viewModelScope.launch { - _pages.value = imageRepository.pages() + val pages = imageRepository.pages() + + _pages.value = pages + + _navigationState.value = + if (pages.isEmpty()) { + NavigationState.initial() + } else { + NavigationState.initial().navigateTo(Screen.Main.Export) + } } } @@ -110,11 +120,11 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode } _currentPageIndex.value = min(_pages.value.size - 1, destination.initialPage) } - _navigationState.update { it.navigateTo(destination) } + _navigationState.update { it?.navigateTo(destination) } } fun navigateBack() { - _navigationState.update { stack -> stack.navigateBack() } + _navigationState.update { stack -> stack?.navigateBack() } } fun rotateCurrentPage(clockwise: Boolean) { diff --git a/app/src/main/java/org/fairscan/app/data/RecentDocuments.kt b/app/src/main/java/org/fairscan/app/data/RecentDocuments.kt deleted file mode 100644 index 0b9f7f88..00000000 --- a/app/src/main/java/org/fairscan/app/data/RecentDocuments.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.data - -import android.content.Context -import androidx.datastore.core.CorruptionException -import androidx.datastore.core.DataStore -import androidx.datastore.core.Serializer -import androidx.datastore.dataStore -import com.google.protobuf.InvalidProtocolBufferException -import org.fairscan.app.RecentDocuments -import java.io.InputStream -import java.io.OutputStream - -object RecentDocumentsSerializer : Serializer { - override val defaultValue: RecentDocuments = RecentDocuments.getDefaultInstance() - - override suspend fun readFrom(input: InputStream): RecentDocuments { - return try { - RecentDocuments.parseFrom(input) - } catch (e: InvalidProtocolBufferException) { - throw CorruptionException("Cannot read proto.", e) - } - } - - override suspend fun writeTo( - t: RecentDocuments, - output: OutputStream - ) = t.writeTo(output) -} - -val Context.recentDocumentsDataStore: DataStore by dataStore( - fileName = "recent_documents.pb", - serializer = RecentDocumentsSerializer -) diff --git a/app/src/main/java/org/fairscan/app/ui/Navigation.kt b/app/src/main/java/org/fairscan/app/ui/Navigation.kt index e84a8d41..a4d9c1ed 100644 --- a/app/src/main/java/org/fairscan/app/ui/Navigation.kt +++ b/app/src/main/java/org/fairscan/app/ui/Navigation.kt @@ -14,11 +14,8 @@ */ package org.fairscan.app.ui -import org.fairscan.app.LaunchMode - sealed class Screen { sealed class Main : Screen() { - object Home : Main() object Camera : Main() data class Document(val initialPage: Int = 0) : Main() object Export : Main() @@ -31,7 +28,6 @@ sealed class Screen { } data class Navigation( - val toHomeScreen: () -> Unit, val toCameraScreen: () -> Unit, val toDocumentScreen: () -> Unit, val toExportScreen: () -> Unit, @@ -41,18 +37,12 @@ data class Navigation( val back: () -> Unit, ) -fun startScreenFor(mode: LaunchMode): Screen.Main = - when (mode) { - LaunchMode.NORMAL -> Screen.Main.Home - LaunchMode.EXTERNAL_SCAN_TO_PDF -> Screen.Main.Camera - } - @ConsistentCopyVisibility data class NavigationState private constructor(val stack: List, val root: Screen.Main) { companion object { - fun initial(mode: LaunchMode): NavigationState { - val root = startScreenFor(mode) + fun initial(): NavigationState { + val root = Screen.Main.Camera return NavigationState(listOf(root), root) } } @@ -70,8 +60,7 @@ data class NavigationState private constructor(val stack: List, val root fun navigateBack(): NavigationState { return when (current) { root -> this // Back handled by system - is Screen.Main.Home -> this // Back handled by system - is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home)) + is Screen.Main.Camera -> this // Back handled by system is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera)) is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera)) is Screen.Overlay -> copy(stack = stack.dropLast(1)) 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 2c6ae748..e4d9a1e0 100644 --- a/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/ui/PreviewUtils.kt @@ -16,7 +16,6 @@ package org.fairscan.app.ui import android.content.Context import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageViewKey @@ -26,11 +25,7 @@ import org.fairscan.app.ui.state.PageThumbnail import org.fairscan.imageprocessing.ColorMode fun dummyNavigation(): Navigation { - return Navigation({}, {}, {}, {}, {}, {}, {}, {}) -} - -fun fakeDocument(): DocumentUiModel { - return DocumentUiModel(persistentListOf()) + return Navigation({}, {}, {}, {}, {}, {}, {}) } fun fakeDocument(pageIds: ImmutableList, context: Context): DocumentUiModel { diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index 27296686..5d9dc49a 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -141,8 +141,6 @@ fun CameraScreen( val isTorchEnabled by cameraViewModel.isTorchEnabled.collectAsStateWithLifecycle() var torchReapplied by remember { mutableStateOf(false) } - BackHandler { navigation.back() } - val captureController = remember { CameraCaptureController() } DisposableEffect(Unit) { onDispose { 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 372c9d29..e84e53a5 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 @@ -140,7 +140,7 @@ fun ExportScreenWrapper( onOpen = pdfActions.open, onCloseScan = { if (!uiState.isSaving) { - if (uiState.hasSavedOrShared) + if (uiState.hasSavedOrShared || uiState.isResumedScan) onCloseScan() else showConfirmationDialog.value = true @@ -149,7 +149,7 @@ fun ExportScreenWrapper( ) if (showConfirmationDialog.value) { - NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.end_scan)) + NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.scan_button)) } } @@ -396,7 +396,7 @@ private fun MainActions( } ExportButton( icon = Icons.Default.Done, - text = stringResource(R.string.end_scan), + text = stringResource(R.string.scan_button), onClick = onCloseScan, modifier = Modifier.fillMaxWidth(), isPrimary = uiState.hasSavedOrShared, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index f4565629..d002a2a4 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -26,6 +26,7 @@ data class ExportUiState( val savedBundle: SavedBundle? = null, val hasShared: Boolean = false, val error: ExportError? = null, + val isResumedScan: Boolean = false, ) { val hasSavedOrShared get() = savedBundle != null || hasShared } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index d8e49d08..43d62232 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -42,7 +42,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fairscan.app.AppContainer import org.fairscan.app.R -import org.fairscan.app.RecentDocument import org.fairscan.app.data.FileManager import org.fairscan.app.data.ImageRepository import org.fairscan.app.domain.ExportQuality @@ -69,7 +68,6 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit private val preparationDir = container.preparationDir private val fileManager = container.fileManager private val settingsRepository = container.settingsRepository - private val recentDocumentsDataStore = container.recentDocumentsDataStore private val logger = container.logger private val _events = MutableSharedFlow() @@ -90,6 +88,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit private val _uiState = MutableStateFlow(ExportUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var resumedScanKeys: List = emptyList() + init { + viewModelScope.launch { + resumedScanKeys = currentPageKeys() + } + } private var lastPreparationKey: ExportPreparationKey? = null private var preparationJob: Job? = null @@ -129,7 +133,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit val exportQuality = settingsRepository.exportQuality.first() val exportFormat = settingsRepository.exportFormat.first() - val key = ExportPreparationKey(currentPageKeys(), exportFormat, exportQuality) + val currentPageKeys = currentPageKeys() + val key = ExportPreparationKey(currentPageKeys, exportFormat, exportQuality) if (key == lastPreparationKey) { return@launch } @@ -139,7 +144,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit preparationJob = launch { _uiState.update { - ExportUiState(filename = it.filename, format = exportFormat, isGenerating = true) + ExportUiState( + filename = it.filename, + format = exportFormat, + isGenerating = true, + isResumedScan = resumedScanKeys == currentPageKeys + ) } try { val t1 = System.currentTimeMillis() @@ -310,12 +320,6 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit val bundle = SavedBundle(savedItems, saveDir) _uiState.update { it.copy(savedBundle = bundle) } - if (exportFormat == ExportFormat.PDF) { - savedItems.forEach { item -> - addRecentDocument(item.uri, item.fileName, result.pageCount) - } - } - filesForMediaScan.forEach { f -> mediaScan(context, f, exportFormat.mimeType) } } @@ -395,27 +399,6 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit DocumentFile.fromTreeUri(context, exportDirUri)?.name } } - - fun addRecentDocument(fileUri: Uri, fileName: String, pageCount: Int) { - viewModelScope.launch { - recentDocumentsDataStore.updateData { current -> - val newDoc = RecentDocument.newBuilder() - .setFileUri(fileUri.toString()) - .setFileName(fileName) - .setPageCount(pageCount) - .setCreatedAt(System.currentTimeMillis()) - .build() - current.toBuilder() - .addDocuments(0, newDoc) - .also { builder -> - while (builder.documentsCount > 3) { - builder.removeDocuments(builder.documentsCount - 1) - } - } - .build() - } - } - } } data class ExportPreparationKey( 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 deleted file mode 100644 index ef1b75d2..00000000 --- a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeScreen.kt +++ /dev/null @@ -1,296 +0,0 @@ -/* - * 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.home - -import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DeleteOutline -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.PictureAsPdf -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import kotlinx.collections.immutable.persistentListOf -import org.fairscan.app.R -import org.fairscan.app.ui.Navigation -import org.fairscan.app.ui.components.AppOverflowMenu -import org.fairscan.app.ui.components.CameraPermissionState -import org.fairscan.app.ui.components.formatDate -import org.fairscan.app.ui.components.pageCountText -import org.fairscan.app.ui.components.rememberCameraPermissionState -import org.fairscan.app.ui.dummyNavigation -import org.fairscan.app.ui.fakeDocument -import org.fairscan.app.ui.state.DocumentUiModel -import org.fairscan.app.ui.theme.FairScanTheme -import java.io.File - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HomeScreen( - cameraPermission: CameraPermissionState, - currentDocument: DocumentUiModel, - navigation: Navigation, - onClearScan: () -> Unit, - recentDocuments: List, - onOpenPdf: (Uri) -> Unit, -) { - Scaffold ( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.app_name)) }, - actions = { AppOverflowMenu(navigation) } - ) - }, - ) { padding -> - Column ( - modifier = Modifier - .padding(padding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - Spacer(Modifier.weight(1f)) - - ScanButton( - onClick = { - onClearScan() - navigation.toCameraScreen() - }, - Modifier.align(Alignment.CenterHorizontally) - ) - - Spacer(Modifier.weight(1f)) - - if (!currentDocument.isEmpty()) { - OngoingScanBanner( - currentDocument, - onResumeScan = navigation.toDocumentScreen, - onClearScan = onClearScan, - ) - } else if (recentDocuments.isNotEmpty()) { - RecentDocumentList(recentDocuments, onOpenPdf) - } - } - } -} - -@Composable -fun ScanButton(onClick: () -> Unit, modifier: Modifier) { - Button( - onClick = onClick, - modifier = modifier.padding(32.dp), - elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp) - ) { - Icon( - imageVector = Icons.Default.PhotoCamera, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(R.string.scan_button), - style = MaterialTheme.typography.titleLarge - ) - - } -} - -@Composable -fun OngoingScanBanner( - currentDocument: DocumentUiModel, - onResumeScan: () -> Unit, - onClearScan: () -> Unit -) { - Surface( - tonalElevation = 2.dp, - shadowElevation = 4.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - currentDocument.thumbnail(0)?.let { - Image( - bitmap = it.asImageBitmap(), - contentDescription = null, - modifier = Modifier - .size(60.dp) - .clip(RoundedCornerShape(4.dp)), - contentScale = ContentScale.Fit - ) - } - - Spacer(Modifier.width(12.dp)) - - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.scan_in_progress), - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = pageCountText(currentDocument.pageCount()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)) - } - - IconButton( - onClick = onClearScan, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.DeleteOutline, - contentDescription = stringResource(R.string.discard_scan), - tint = MaterialTheme.colorScheme.primary - ) - } - Spacer(Modifier.width(12.dp)) - Button(onClick = onResumeScan) { - Text(stringResource(R.string.resume)) - } - } - } -} - -@Composable -private fun RecentDocumentList( - recentDocuments: List, - onOpenPdf: (Uri) -> Unit -) { - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.last_saved_pdf_files), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 12.dp, bottom = 4.dp) - ) - Column { - recentDocuments.forEach { doc -> - ListItem( - headlineContent = { - Text( - doc.fileName, - style = MaterialTheme.typography.bodyMedium - ) - }, - supportingContent = { - Text( - text = pageCountText(doc.pageCount) + " • " + - formatDate(doc.saveTimestamp, LocalContext.current), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - leadingContent = { - Icon( - Icons.Default.PictureAsPdf, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - modifier = Modifier.clickable { onOpenPdf(doc.fileUri) } - ) - } - } -} - -@Preview -@Composable -fun HomeScreenPreviewOnFirstLaunch() { - FairScanTheme { - HomeScreen( - cameraPermission = rememberCameraPermissionState(), - currentDocument = fakeDocument(), - navigation = dummyNavigation(), - onClearScan = {}, - recentDocuments = listOf(), - onOpenPdf = {}, - ) - } -} - -@Preview -@Composable -fun HomeScreenPreviewWithCurrentDocument() { - FairScanTheme { - HomeScreen( - cameraPermission = rememberCameraPermissionState(), - currentDocument = fakeDocument( - persistentListOf("gallica.bnf.fr-bpt6k5530456s-1"), - LocalContext.current - ), - navigation = dummyNavigation(), - onClearScan = {}, - recentDocuments = listOf(), - onOpenPdf = {}, - ) - } -} - -@Preview -@Composable -fun HomeScreenPreviewWithLastSavedFiles() { - FairScanTheme { - HomeScreen( - cameraPermission = rememberCameraPermissionState(), - currentDocument = fakeDocument(), - navigation = dummyNavigation(), - onClearScan = {}, - recentDocuments = listOf( - RecentDocumentUiState( - File("/path/my_file.pdf").toUri(), "my_file.pdf", 1755971180000, 3), - RecentDocumentUiState( - "content:///path/scan2.pdf".toUri(), "scan2.pdf",1755000500000, 1) - ), - onOpenPdf = {}, - ) - } -} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/home/HomeUiState.kt deleted file mode 100644 index 77f43238..00000000 --- a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeUiState.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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.home - -import android.net.Uri - -data class RecentDocumentUiState( - val fileUri: Uri, - val fileName: String, - val saveTimestamp: Long, - val pageCount: Int, -) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/home/HomeViewModel.kt deleted file mode 100644 index 97a98d33..00000000 --- a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeViewModel.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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.home - -import android.content.Context -import android.net.Uri -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import org.fairscan.app.AppContainer -import java.io.File - -class HomeViewModel(appContainer: AppContainer, appContext: Context): ViewModel() { - - private val recentDocumentsDataStore = appContainer.recentDocumentsDataStore - - val recentDocuments: StateFlow> = - recentDocumentsDataStore.data.map { - it.documentsList.mapNotNull { doc -> - var fileName = doc.fileName - var uri: Uri? = null - if (doc.fileUri.isNullOrEmpty()) { - if (!doc.filePath.isNullOrEmpty()) { - val file = File(doc.filePath) - uri = file.toUri() - fileName = file.name - } - } else { - uri = doc.fileUri.toUri() - } - if (uri != null) { - RecentDocumentUiState( - fileUri = uri, - fileName = fileName, - saveTimestamp = doc.createdAt, - pageCount = doc.pageCount, - ) - } else null - }.filter { item -> uriExists(appContext, item.fileUri) } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) - - private fun uriExists(context: Context, uri: Uri): Boolean { - return if (uri.scheme == "file") { - File(uri.path.orEmpty()).exists() - } else { - try { - DocumentFile.fromSingleUri(context, uri)?.exists() == true - } catch (_: Exception) { - false - } - } - } - -} 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 c0dea9f4..521e05a1 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 @@ -33,7 +33,7 @@ data class DocumentUiModel( return pages.lastIndex } fun thumbnail(index: Int): Bitmap? { - return pages[index].thumbnail?.toBitmap() + return pages.getOrNull(index)?.thumbnail?.toBitmap() } } diff --git a/app/src/main/proto/recent_documents.proto b/app/src/main/proto/recent_documents.proto deleted file mode 100644 index f31441f0..00000000 --- a/app/src/main/proto/recent_documents.proto +++ /dev/null @@ -1,16 +0,0 @@ -syntax = "proto3"; - -option java_package = "org.fairscan.app"; -option java_multiple_files = true; - -message RecentDocument { - string file_path = 1; - int64 created_at = 2; // timestamp in ms - int32 page_count = 3; - string file_uri = 4; - string file_name = 5; -} - -message RecentDocuments { - repeated RecentDocument documents = 1; -} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1da27a23..8fe6990f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -22,7 +22,6 @@ مطوّر أهمِل المسح التنزيلات - أنهِ المسح خطأ: %1$s مجلد التصدير: %1$s المزود: %1$s @@ -44,7 +43,6 @@ اسم الملف امنح الأذن استيراد - ملفات PDF المحفوظة حديثًا على هذا الجهاز: المكتبات مكتبات مفتوحة المصدر الترخيص diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 644127e8..04d7f35b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -22,7 +22,6 @@ Vývojář Zrušit skenování stažených - Ukončit skenování Chyba: %1$s Složka exportu: %1$s Poskytovatel: %1$s @@ -44,7 +43,6 @@ Název souboru Povolit přístup Import - Poslední PDF uložené v tomto zařízení: Kníhovny Open-source knihovny Licence diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ff408374..8d6a16c1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -22,7 +22,6 @@ Entwickler Löschen Downloads - Scan beenden Fehler: %1$s Exportordner: %1$s Anbieter: %1$s @@ -44,7 +43,6 @@ Dateiname Berechtigung erteilen Importieren - Zuletzt auf diesem Gerät gespeicherte PDFs: Bibliotheken Open-Source-Bibliotheken Lizenz diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 33c661be..6837d41f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -22,7 +22,6 @@ Desarrollador Descartar escaneo Descargas - Finalizar escaneo Error: %1$s Carpeta de exportación: %1$s Proveedor: %1$s @@ -44,7 +43,6 @@ Nombre del archivo Conceder permiso Importar - PDF recientes guardados en este dispositivo: Bibliotecas Bibliotecas de código abierto Licencia diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 520e5146..25b62f34 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -22,7 +22,6 @@ Développeur Supprimer le scan Téléchargements - Terminer le scan Erreur : %1$s Dossier d’export : %1$s Fournisseur : %1$s @@ -44,7 +43,6 @@ Nom de fichier Autoriser Importer - Derniers PDF enregistrés sur l’appareil : Bibliothèques Bibliothèques open source Licence diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 38a49264..7808d63b 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -22,7 +22,6 @@ Desenvolvedor Descartar escaneo Descargas - Rematar escaneo Erro: %1$s Cartafol de exportación: %1$s Provedor: %1$s @@ -44,7 +43,6 @@ Nome do ficheiro Conceder permiso Importar - PDF recentes gardados neste dispositivo: Bibliotecas Bibliotecas de código aberto Licenza diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7bc9ed0d..e5542325 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -22,7 +22,6 @@ Sviluppatore Scarta scansione Download - Termina scansione Errore: %1$s Cartella di esportazione: %1$s Provider: %1$s @@ -44,7 +43,6 @@ Nome file Concendi autorizzazione Importa - PDF recenti salvati su questo dispositivo: Librerie Librerie open source Licenza diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 23c16d05..32f67c12 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -22,7 +22,6 @@ Desenvolvedor Descartar digitalização Downloads - Finalizar digitalização Erro: %1$s Pasta de exportação: %1$s Provedor: %1$s @@ -44,7 +43,6 @@ Nome do arquivo Conceder permissão Importar - PDFs recentes salvos neste dispositivo: Bibliotecas Bibliotecas de código aberto Licença diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ad10ec81..2edb8f8a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -22,7 +22,6 @@ Разработчик Отказаться Download - Закончить Ошибка: %1$s Папка экспорта: %1$s Провайдер: %1$s @@ -44,7 +43,6 @@ Имя файла Предоставить разрешение Импорт - Последние PDF, сохранённые на этом устройстве: Библиотеки Библиотеки с открытым исходным кодом Лицензия diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 64faf482..c5650c94 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -22,7 +22,6 @@ Geliştirici Taramayı at İndirilenler - Taramayı bitir Error: %1$s Dışa aktarma klasörü: %1$s Sağlayıcı: %1$s @@ -44,7 +43,6 @@ Dosya adı İzin ver İçe aktar - Bu cihaza kaydedilen son PDF\'ler: Kütüphaneler Açık kaynaklı kütüphaneler Lisans diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 265b31ee..eb5c9066 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -22,7 +22,6 @@ 開發者 捨棄掃描 下載 (Downloads) - 結束掃描 錯誤:%1$s 匯出資料夾:%1$s 提供者:%1$s @@ -44,7 +43,6 @@ 檔案名稱 授予權限 匯入 - 此裝置上最近儲存的 PDF: 函式庫 開放原始碼函式庫 許可證 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0cfe2d29..3a8e9268 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -22,7 +22,6 @@ 开发者 放弃扫描 下载 - 结束扫描 错误: %1$s 导出文件夹:%1$s 提供方:%1$s @@ -44,7 +43,6 @@ 文件名字 授予权限 导入 - 最近保存在此设备上的 PDF: 开源库 许可证 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1e56bca..951ba307 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,7 +23,6 @@ Developer Discard scan Downloads - End scan Error: %1$s Export folder: %1$s Provider: %1$s @@ -48,7 +47,6 @@ Filename Grant permission Import - Recent PDFs saved on this device: Libraries Open-source libraries License diff --git a/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt b/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt index cb276acc..08b9ec58 100644 --- a/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt +++ b/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt @@ -15,11 +15,9 @@ package org.fairscan.app.ui import org.assertj.core.api.Assertions.assertThat -import org.fairscan.app.LaunchMode import org.fairscan.app.ui.Screen.Main.Camera import org.fairscan.app.ui.Screen.Main.Document import org.fairscan.app.ui.Screen.Main.Export -import org.fairscan.app.ui.Screen.Main.Home import org.fairscan.app.ui.Screen.Overlay.About import org.fairscan.app.ui.Screen.Overlay.Libraries import org.junit.Test @@ -28,36 +26,33 @@ class NavigationTest { @Test fun empty_ScreenStack() { - val empty = NavigationState.initial(LaunchMode.NORMAL) - assertThat(empty.current).isEqualTo(Home) + val empty = NavigationState.initial() + assertThat(empty.current).isEqualTo(Camera) assertThat(empty.navigateBack()).isEqualTo(empty) } @Test fun navigate_between_fixed_screens() { - val atHome = NavigationState.initial(LaunchMode.NORMAL) - val atCamera = atHome.navigateTo(Camera) - val atDocument = atHome.navigateTo(Document()) - val atExport = atHome.navigateTo(Export) + val atCamera = NavigationState.initial() + val atDocument = atCamera.navigateTo(Document()) + val atExport = atCamera.navigateTo(Export) - assertThat(atHome.current).isEqualTo(Home) assertThat(atCamera.current).isEqualTo(Camera) assertThat(atDocument.current).isEqualTo(Document()) assertThat(atExport.current).isEqualTo(Export) assertThat(atCamera.navigateTo(Document())).isEqualTo(atDocument) - assertThat(atDocument.navigateTo(Home)).isEqualTo(atHome) + assertThat(atDocument.navigateTo(Export)).isEqualTo(atExport) assertThat(atDocument.navigateTo(Camera)).isEqualTo(atCamera) - assertThat(atHome.navigateBack()).isEqualTo(atHome) - assertThat(atCamera.navigateBack()).isEqualTo(atHome) + assertThat(atCamera.navigateBack()).isEqualTo(atCamera) assertThat(atDocument.navigateBack()).isEqualTo(atCamera) assertThat(atExport.navigateBack()).isEqualTo(atCamera) } @Test fun navigate_to_secondary_screens() { - val atHome = NavigationState.initial(LaunchMode.NORMAL) + val atHome = NavigationState.initial() val atCamera = atHome.navigateTo(Camera) val atAboutAfterHome = atHome.navigateTo(About) @@ -75,7 +70,7 @@ class NavigationTest { @Test fun external_call() { - val initial = NavigationState.initial(LaunchMode.EXTERNAL_SCAN_TO_PDF) + val initial = NavigationState.initial() assertThat(initial.current).isEqualTo(Camera) assertThat(initial.navigateBack().current).isEqualTo(Camera) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f07fd0d..00f16d87 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,8 +19,6 @@ assertj = "3.27.7" pdfbox = "2.0.27.0" zoomable = "2.11.1" aboutLibraries = "13.2.1" -protobuf = "0.9.6" -protobufJavaLite = "4.34.1" kotlinSerialization = "1.10.0" reorderable = "3.0.0" jetbrainsKotlinJvm = "2.3.10" @@ -49,10 +47,8 @@ androidx-camera-core = { group = "androidx.camera", name = "camera-core", versio androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } -androidx-datastore = { group = "androidx.datastore", name = "datastore" , version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences" , version.ref = "datastore" } androidx-documentfile = { group = "androidx.documentfile", name = "documentfile" , version.ref = "documentfile" } -protobuf-javalite = { group = "com.google.protobuf", name="protobuf-javalite", version.ref = "protobufJavaLite"} litert = { group = "com.google.ai.edge.litert", name = "litert", version.ref = "litert" } litert-support = { group = "com.google.ai.edge.litert", name = "litert-support", version.ref = "litert" } litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata", version.ref = "litert" } @@ -75,5 +71,4 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } license = { id = "com.github.hierynomus.license", version.ref = "license" } aboutLibrariesAndroid = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } -protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }