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.compose)
alias(libs.plugins.aboutLibraries)
alias(libs.plugins.protobuf)
}
android {
@@ -85,6 +86,8 @@ dependencies {
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.datastore)
implementation(libs.protobuf.javalite)
implementation(libs.litert)
implementation(libs.litert.support)
implementation(libs.litert.metadata)
@@ -107,3 +110,19 @@ dependencies {
aboutLibraries {
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.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
@@ -72,11 +73,14 @@ class MainActivity : ComponentActivity() {
)
when (val screen = currentScreen) {
is Screen.Home -> {
val recentDocs by viewModel.recentDocuments.collectAsStateWithLifecycle()
HomeScreen(
cameraPermission = cameraPermission,
currentDocument = document,
navigation = navigation,
onStartNewScan = navigation.toCameraScreen,
recentDocuments = recentDocs,
onOpenPdf = { file -> openPdf(file.toUri()) }
)
}
is Screen.Camera -> {
@@ -149,6 +153,7 @@ class MainActivity : ComponentActivity() {
appScope.launch {
try {
val targetFile = viewModel.saveFile(generatedPdf.file)
viewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount)
suspendCancellableCoroutine { continuation ->
MediaScannerConnection.scanFile(

View File

@@ -21,6 +21,7 @@ import android.os.Environment
import android.util.Log
import androidx.camera.core.ImageProxy
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -37,7 +38,9 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.mydomain.myscan.data.recentDocumentsDataStore
import org.mydomain.myscan.ui.PdfGenerationUiState
import org.mydomain.myscan.ui.RecentDocumentUiState
import org.mydomain.myscan.view.DocumentUiModel
import java.io.ByteArrayOutputStream
import java.io.File
@@ -46,6 +49,7 @@ class MainViewModel(
private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository,
private val pdfFileManager: PdfFileManager,
private val recentDocumentsDataStore: DataStore<RecentDocuments>,
): ViewModel() {
companion object {
@@ -59,6 +63,7 @@ class MainViewModel(
File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()),
context.recentDocumentsDataStore,
) as T
}
}
@@ -314,6 +319,42 @@ class MainViewModel(
fun cleanUpOldPdfs(thresholdInMillis: Int) {
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(

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 org.mydomain.myscan.GeneratedPdf
import java.io.File
data class PdfGenerationUiState(
val isGenerating: Boolean = false,
@@ -25,3 +26,9 @@ data class PdfGenerationUiState(
val saveDirectoryName: 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
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
@@ -27,10 +28,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.Card
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.Scaffold
import androidx.compose.material3.Text
@@ -49,7 +54,10 @@ import org.mydomain.myscan.CameraPermissionState
import org.mydomain.myscan.Navigation
import org.mydomain.myscan.R
import org.mydomain.myscan.rememberCameraPermissionState
import org.mydomain.myscan.ui.RecentDocumentUiState
import org.mydomain.myscan.ui.theme.MyScanTheme
import java.io.File
import kotlin.math.min
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -57,7 +65,9 @@ fun HomeScreen(
cameraPermission: CameraPermissionState,
currentDocument: DocumentUiModel,
navigation: Navigation,
onStartNewScan: () -> Unit
onStartNewScan: () -> Unit,
recentDocuments: List<RecentDocumentUiState>,
onOpenPdf: (File) -> Unit,
) {
val showCloseDocDialog = rememberSaveable { mutableStateOf(false) }
Scaffold (
@@ -104,6 +114,11 @@ fun HomeScreen(
CurrentDocumentCard(currentDocument, navigation)
}
if (recentDocuments.isNotEmpty()) {
SectionTitle(stringResource(R.string.last_saved_documents))
RecentDocumentList(recentDocuments, onOpenPdf)
}
if (showCloseDocDialog.value) {
NewDocumentDialog(
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
private fun SectionTitle(text: String) {
Text(
@@ -183,7 +224,9 @@ fun HomeScreenPreviewOnFirstLaunch() {
cameraPermission = rememberCameraPermissionState(),
currentDocument = DocumentUiModel(listOf()) { _ -> null },
navigation = dummyNavigation(),
onStartNewScan = {}
onStartNewScan = {},
recentDocuments = listOf(),
onOpenPdf = {},
)
}
}
@@ -198,7 +241,12 @@ fun HomeScreenPreviewWithCurrentDocument() {
listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
LocalContext.current),
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
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import org.mydomain.myscan.R
import java.util.Date
@Composable
fun pageCountText(quantity: Int): String {
val context = LocalContext.current
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="filename">Nom de fichier</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_intro">Cette application utilise plusieurs bibliothèques open source, notamment :</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="filename">Filename</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_intro">This application uses several open-source libraries, including:</string>
<string name="libraries_open_source">Open-source libraries</string>

View File

@@ -10,12 +10,15 @@ lifecycleRuntimeKtx = "2.9.1"
activityCompose = "1.10.1"
composeBom = "2025.06.01"
camerax = "1.4.2"
datastore = "1.1.7"
litert = "1.4.0"
opencv = "4.12.0"
assertj = "3.27.3"
pdfbox = "2.0.27.0"
zoomable = "2.8.1"
aboutLibraries = "12.2.4"
protobuf = "0.9.5"
protobufJavaLite = "4.32.0"
[libraries]
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-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" }
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" }
@@ -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" }
license = { id = "com.github.hierynomus.license", version.ref = "license" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }