HomeScreen: list of recent documents
This commit is contained in:
committed by
pynicolas
parent
eb1f3b64ed
commit
f3e814b93a
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
14
app/src/main/proto/recent_documents.proto
Normal file
14
app/src/main/proto/recent_documents.proto
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user