HomeScreen: list of recent documents

This commit is contained in:
Pierre-Yves Nicolas
2025-08-24 08:36:01 +02:00
committed by pynicolas
parent eb1f3b64ed
commit f3e814b93a
11 changed files with 201 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries) alias(libs.plugins.aboutLibraries)
alias(libs.plugins.protobuf)
} }
android { android {
@@ -85,6 +86,8 @@ dependencies {
implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.view)
implementation(libs.androidx.datastore)
implementation(libs.protobuf.javalite)
implementation(libs.litert) implementation(libs.litert)
implementation(libs.litert.support) implementation(libs.litert.support)
implementation(libs.litert.metadata) implementation(libs.litert.metadata)
@@ -107,3 +110,19 @@ dependencies {
aboutLibraries { aboutLibraries {
android.registerAndroidTasks = true android.registerAndroidTasks = true
} }
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.32.0"
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
}
}
}

View File

@@ -29,6 +29,7 @@ import androidx.activity.viewModels
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -72,11 +73,14 @@ class MainActivity : ComponentActivity() {
) )
when (val screen = currentScreen) { when (val screen = currentScreen) {
is Screen.Home -> { is Screen.Home -> {
val recentDocs by viewModel.recentDocuments.collectAsStateWithLifecycle()
HomeScreen( HomeScreen(
cameraPermission = cameraPermission, cameraPermission = cameraPermission,
currentDocument = document, currentDocument = document,
navigation = navigation, navigation = navigation,
onStartNewScan = navigation.toCameraScreen, onStartNewScan = navigation.toCameraScreen,
recentDocuments = recentDocs,
onOpenPdf = { file -> openPdf(file.toUri()) }
) )
} }
is Screen.Camera -> { is Screen.Camera -> {
@@ -149,6 +153,7 @@ class MainActivity : ComponentActivity() {
appScope.launch { appScope.launch {
try { try {
val targetFile = viewModel.saveFile(generatedPdf.file) val targetFile = viewModel.saveFile(generatedPdf.file)
viewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount)
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
MediaScannerConnection.scanFile( MediaScannerConnection.scanFile(

View File

@@ -21,6 +21,7 @@ import android.os.Environment
import android.util.Log import android.util.Log
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -37,7 +38,9 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update 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.data.recentDocumentsDataStore
import org.mydomain.myscan.ui.PdfGenerationUiState import org.mydomain.myscan.ui.PdfGenerationUiState
import org.mydomain.myscan.ui.RecentDocumentUiState
import org.mydomain.myscan.view.DocumentUiModel import org.mydomain.myscan.view.DocumentUiModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
@@ -46,6 +49,7 @@ class MainViewModel(
private val imageSegmentationService: ImageSegmentationService, private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val pdfFileManager: PdfFileManager, private val pdfFileManager: PdfFileManager,
private val recentDocumentsDataStore: DataStore<RecentDocuments>,
): ViewModel() { ): ViewModel() {
companion object { companion object {
@@ -59,6 +63,7 @@ class MainViewModel(
File(context.cacheDir, "pdfs"), File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()), AndroidPdfWriter()),
context.recentDocumentsDataStore,
) as T ) as T
} }
} }
@@ -314,6 +319,42 @@ class MainViewModel(
fun cleanUpOldPdfs(thresholdInMillis: Int) { fun cleanUpOldPdfs(thresholdInMillis: Int) {
pdfFileManager.cleanUpOldFiles(thresholdInMillis) pdfFileManager.cleanUpOldFiles(thresholdInMillis)
} }
val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
recentDocumentsDataStore.data.map {
it.documentsList.map {
doc ->
RecentDocumentUiState(
file = File(doc.filePath),
saveTimestamp = doc.createdAt,
pageCount = doc.pageCount,
)
}.filter { doc -> doc.file.exists() }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun addRecentDocument(filePath: String, pageCount: Int) {
viewModelScope.launch {
recentDocumentsDataStore.updateData { current ->
val newDoc = RecentDocument.newBuilder()
.setFilePath(filePath)
.setPageCount(pageCount)
.setCreatedAt(System.currentTimeMillis())
.build()
current.toBuilder()
.addDocuments(0, newDoc)
.also { builder ->
while (builder.documentsCount > 10) {
builder.removeDocuments(builder.documentsCount - 1)
}
}
.build()
}
}
}
} }
data class GeneratedPdf( data class GeneratedPdf(

View File

@@ -0,0 +1,47 @@
/*
* 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.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.mydomain.myscan.RecentDocuments
import java.io.InputStream
import java.io.OutputStream
object RecentDocumentsSerializer : Serializer<RecentDocuments> {
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<RecentDocuments> by dataStore(
fileName = "recent_documents.pb",
serializer = RecentDocumentsSerializer
)

View File

@@ -16,6 +16,7 @@ package org.mydomain.myscan.ui
import android.net.Uri import android.net.Uri
import org.mydomain.myscan.GeneratedPdf import org.mydomain.myscan.GeneratedPdf
import java.io.File
data class PdfGenerationUiState( data class PdfGenerationUiState(
val isGenerating: Boolean = false, val isGenerating: Boolean = false,
@@ -25,3 +26,9 @@ data class PdfGenerationUiState(
val saveDirectoryName: String? = null, val saveDirectoryName: String? = null,
val errorMessage: String? = null val errorMessage: String? = null
) )
data class RecentDocumentUiState(
val file: File,
val saveTimestamp: Long,
val pageCount: Int,
)

View File

@@ -15,6 +15,7 @@
package org.mydomain.myscan.view package org.mydomain.myscan.view
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -27,10 +28,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -49,7 +54,10 @@ import org.mydomain.myscan.CameraPermissionState
import org.mydomain.myscan.Navigation import org.mydomain.myscan.Navigation
import org.mydomain.myscan.R import org.mydomain.myscan.R
import org.mydomain.myscan.rememberCameraPermissionState import org.mydomain.myscan.rememberCameraPermissionState
import org.mydomain.myscan.ui.RecentDocumentUiState
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
import java.io.File
import kotlin.math.min
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -57,7 +65,9 @@ fun HomeScreen(
cameraPermission: CameraPermissionState, cameraPermission: CameraPermissionState,
currentDocument: DocumentUiModel, currentDocument: DocumentUiModel,
navigation: Navigation, navigation: Navigation,
onStartNewScan: () -> Unit onStartNewScan: () -> Unit,
recentDocuments: List<RecentDocumentUiState>,
onOpenPdf: (File) -> Unit,
) { ) {
val showCloseDocDialog = rememberSaveable { mutableStateOf(false) } val showCloseDocDialog = rememberSaveable { mutableStateOf(false) }
Scaffold ( Scaffold (
@@ -104,6 +114,11 @@ fun HomeScreen(
CurrentDocumentCard(currentDocument, navigation) CurrentDocumentCard(currentDocument, navigation)
} }
if (recentDocuments.isNotEmpty()) {
SectionTitle(stringResource(R.string.last_saved_documents))
RecentDocumentList(recentDocuments, onOpenPdf)
}
if (showCloseDocDialog.value) { if (showCloseDocDialog.value) {
NewDocumentDialog( NewDocumentDialog(
onConfirm = onStartNewScan, onConfirm = onStartNewScan,
@@ -166,6 +181,32 @@ private fun CurrentDocumentCard(
} }
} }
@Composable
private fun RecentDocumentList(
recentDocuments: List<RecentDocumentUiState>,
onOpenPdf: (File) -> Unit
) {
Column {
val maxListSize = 5
recentDocuments.subList(0, min(maxListSize, recentDocuments.size)).forEach { doc ->
ListItem(
headlineContent = { Text(doc.file.name) },
supportingContent = {
Text(
text = pageCountText(doc.pageCount) + "" +
formatDate(doc.saveTimestamp, LocalContext.current)
)
},
leadingContent = {
Icon(Icons.Default.PictureAsPdf, contentDescription = null)
},
modifier = Modifier.clickable { onOpenPdf(doc.file) }
)
HorizontalDivider()
}
}
}
@Composable @Composable
private fun SectionTitle(text: String) { private fun SectionTitle(text: String) {
Text( Text(
@@ -183,7 +224,9 @@ fun HomeScreenPreviewOnFirstLaunch() {
cameraPermission = rememberCameraPermissionState(), cameraPermission = rememberCameraPermissionState(),
currentDocument = DocumentUiModel(listOf()) { _ -> null }, currentDocument = DocumentUiModel(listOf()) { _ -> null },
navigation = dummyNavigation(), navigation = dummyNavigation(),
onStartNewScan = {} onStartNewScan = {},
recentDocuments = listOf(),
onOpenPdf = {},
) )
} }
} }
@@ -198,7 +241,12 @@ fun HomeScreenPreviewWithCurrentDocument() {
listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"), listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
LocalContext.current), LocalContext.current),
navigation = dummyNavigation(), navigation = dummyNavigation(),
onStartNewScan = {} onStartNewScan = {},
recentDocuments = listOf(
RecentDocumentUiState(File("/path/my_file.pdf"), 1755971180000, 3),
RecentDocumentUiState(File("/path/scan2.pdf"), 1755000500000, 1)
),
onOpenPdf = {},
) )
} }
} }

View File

@@ -14,12 +14,21 @@
*/ */
package org.mydomain.myscan.view package org.mydomain.myscan.view
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import org.mydomain.myscan.R import org.mydomain.myscan.R
import java.util.Date
@Composable @Composable
fun pageCountText(quantity: Int): String { fun pageCountText(quantity: Int): String {
val context = LocalContext.current val context = LocalContext.current
return context.resources.getQuantityString(R.plurals.page_count, quantity, quantity) return context.resources.getQuantityString(R.plurals.page_count, quantity, quantity)
} }
fun formatDate(timestamp: Long, context: Context): String {
val date = Date(timestamp)
return DateFormat.getMediumDateFormat(context).format(date) + " " +
DateFormat.getTimeFormat(context).format(date)
}

View File

@@ -0,0 +1,14 @@
syntax = "proto3";
option java_package = "org.mydomain.myscan";
option java_multiple_files = true;
message RecentDocument {
string file_path = 1;
int64 created_at = 2; // timestamp in ms
int32 page_count = 3;
}
message RecentDocuments {
repeated RecentDocument documents = 1;
}

View File

@@ -19,6 +19,7 @@
<string name="export_pdf">Exporter en PDF</string> <string name="export_pdf">Exporter en PDF</string>
<string name="filename">Nom de fichier</string> <string name="filename">Nom de fichier</string>
<string name="grant_permission">Autoriser</string> <string name="grant_permission">Autoriser</string>
<string name="last_saved_documents">Derniers documents enregistrés</string>
<string name="libraries">Bibliothèques</string> <string name="libraries">Bibliothèques</string>
<string name="libraries_intro">Cette application utilise plusieurs bibliothèques open source, notamment :</string> <string name="libraries_intro">Cette application utilise plusieurs bibliothèques open source, notamment :</string>
<string name="libraries_open_source">Bibliothèques open source</string> <string name="libraries_open_source">Bibliothèques open source</string>

View File

@@ -19,6 +19,7 @@
<string name="export_pdf">Export PDF</string> <string name="export_pdf">Export PDF</string>
<string name="filename">Filename</string> <string name="filename">Filename</string>
<string name="grant_permission">Grant permission</string> <string name="grant_permission">Grant permission</string>
<string name="last_saved_documents">Last saved documents</string>
<string name="libraries">Libraries</string> <string name="libraries">Libraries</string>
<string name="libraries_intro">This application uses several open-source libraries, including:</string> <string name="libraries_intro">This application uses several open-source libraries, including:</string>
<string name="libraries_open_source">Open-source libraries</string> <string name="libraries_open_source">Open-source libraries</string>

View File

@@ -10,12 +10,15 @@ lifecycleRuntimeKtx = "2.9.1"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2025.06.01" composeBom = "2025.06.01"
camerax = "1.4.2" camerax = "1.4.2"
datastore = "1.1.7"
litert = "1.4.0" litert = "1.4.0"
opencv = "4.12.0" opencv = "4.12.0"
assertj = "3.27.3" assertj = "3.27.3"
pdfbox = "2.0.27.0" pdfbox = "2.0.27.0"
zoomable = "2.8.1" zoomable = "2.8.1"
aboutLibraries = "12.2.4" aboutLibraries = "12.2.4"
protobuf = "0.9.5"
protobufJavaLite = "4.32.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -40,6 +43,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-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", 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-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
androidx-datastore = { group = "androidx.datastore", name = "datastore" , version.ref = "datastore" }
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 = { 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-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" } litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata", version.ref = "litert" }
@@ -56,4 +61,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
license = { id = "com.github.hierynomus.license", version.ref = "license" } license = { id = "com.github.hierynomus.license", version.ref = "license" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }