From 97b4d333d482af08eb994d350e060933ccb1c2fb Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 5 Jul 2025 18:30:18 +0200 Subject: [PATCH] PDF generation: change to a BottomSheet, button to "open" the saved PDF --- .../java/org/mydomain/myscan/MainActivity.kt | 40 +++- .../java/org/mydomain/myscan/MainViewModel.kt | 56 ++++- .../java/org/mydomain/myscan/ui/UiState.kt | 26 +++ .../mydomain/myscan/view/DocumentScreen.kt | 5 +- .../myscan/view/PdfGenerationBottomSheet.kt | 215 ++++++++++++++++++ .../myscan/view/PdfGenerationDialog.kt | 167 -------------- app/src/main/res/xml/file_paths.xml | 3 + 7 files changed, 322 insertions(+), 190 deletions(-) create mode 100644 app/src/main/java/org/mydomain/myscan/ui/UiState.kt create mode 100644 app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt delete mode 100644 app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index 56f1d2c..c3acca3 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -14,9 +14,11 @@ */ package org.mydomain.myscan +import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.PackageManager import android.media.MediaScannerConnection +import android.net.Uri import android.os.Bundle import android.os.Environment import android.util.Log @@ -28,6 +30,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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,6 +43,8 @@ import org.mydomain.myscan.view.DocumentScreen import org.opencv.android.OpenCVLoader import java.io.File +private const val PDF_MIME_TYPE = "application/pdf" + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -71,9 +76,10 @@ class MainActivity : ComponentActivity() { startGeneration = viewModel::startPdfGeneration, cancelGeneration = viewModel::cancelPdfGeneration, setFilename = viewModel::setFilename, - generatedPdfFlow = viewModel.generatedPdf, + uiStateFlow = viewModel.pdfUiState, sharePdf = { sharePdf(viewModel.getFinalPdf()) }, - savePdf = { savePdf(viewModel.getFinalPdf()) }, + savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) }, + openPdf = { openPdf(viewModel.pdfUiState.value.savedFileUri) } ), onStartNew = { viewModel.startNewDocument() @@ -93,7 +99,7 @@ class MainActivity : ComponentActivity() { val authority = "${applicationContext.packageName}.fileprovider" val fileUri = FileProvider.getUriForFile(this, authority, file) val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "application/pdf" + type = PDF_MIME_TYPE putExtra(Intent.EXTRA_STREAM, fileUri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -107,7 +113,7 @@ class MainActivity : ComponentActivity() { startActivity(chooser) } - private fun savePdf(generatedPdf: GeneratedPdf?) { + private fun savePdf(generatedPdf: GeneratedPdf?, viewModel: MainViewModel) { if (generatedPdf == null) return val appScope = CoroutineScope(Dispatchers.IO) @@ -123,17 +129,13 @@ class MainActivity : ComponentActivity() { val targetFile = File(downloadsDir, generatedFile.name) // TODO Handle case where the target file already exists (choose a unique name) generatedFile.copyTo(targetFile) - - withContext(Dispatchers.Main) { - // TODO Display a link to the file - Toast.makeText(context, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show() - } + viewModel.markFileSaved(targetFile.toUri()) suspendCancellableCoroutine { continuation -> MediaScannerConnection.scanFile( context, arrayOf(targetFile.absolutePath), - arrayOf("application/pdf") + arrayOf(PDF_MIME_TYPE) ) { _, _ -> continuation.resume(Unit) {} } } } catch (e: Exception) { @@ -145,6 +147,24 @@ class MainActivity : ComponentActivity() { } } + private fun openPdf(fileUri: Uri?) { + if (fileUri == null) return + val uri = FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileprovider", + fileUri.toFile() + ) + val openIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, PDF_MIME_TYPE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + try { + startActivity(Intent.createChooser(openIntent, "Open PDF")) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, "No app found to open PDF", Toast.LENGTH_SHORT).show() + } + } + private fun initLibraries() { com.tom_roush.pdfbox.android.PDFBoxResourceLoader.init(applicationContext) diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index d192bbb..ceed9b4 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -18,6 +18,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import android.util.Log import androidx.camera.core.ImageProxy import androidx.core.net.toFile import androidx.core.net.toUri @@ -32,8 +33,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.mydomain.myscan.ui.PdfGenerationUiState import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream @@ -208,7 +211,9 @@ class MainViewModel( } private val _generatedPdf = MutableStateFlow(null) - val generatedPdf: StateFlow = _generatedPdf + + private val _pdfUiState = MutableStateFlow(PdfGenerationUiState()) + val pdfUiState: StateFlow = _pdfUiState.asStateFlow() private var generationJob: Job? = null private var desiredFilename: String = "" @@ -218,29 +223,56 @@ class MainViewModel( } fun startPdfGeneration() { - if (_generatedPdf.value != null) return + val currentState = _pdfUiState.value + if (currentState.isGenerating || currentState.generatedPdf != null) return + + _pdfUiState.update { it.copy(isGenerating = true, errorMessage = null) } + generationJob = viewModelScope.launch { - val result = generatePdf() - _generatedPdf.value = result + try { + val result = generatePdf() + _pdfUiState.update { + it.copy( + isGenerating = false, + generatedPdf = result + ) + } + } catch (e: Exception) { + Log.e("MyScan", "PDF generation failed", e) + _pdfUiState.update { + it.copy( + isGenerating = false, + errorMessage = "PDF generation failed" + ) + } + } } } fun cancelPdfGeneration() { generationJob?.cancel() - _generatedPdf.value = null + _pdfUiState.value = PdfGenerationUiState() } fun getFinalPdf(): GeneratedPdf? { - val temp = _generatedPdf.value ?: return null - val tempFile = temp.uri.toFile() + val tempPdf = _pdfUiState.value.generatedPdf ?: return null + val tempFile = tempPdf.uri.toFile() val newFile = File(tempFile.parentFile, desiredFilename) if (tempFile.absolutePath != newFile.absolutePath) { if (newFile.exists()) newFile.delete() val success = tempFile.renameTo(newFile) if (!success) return null - _generatedPdf.value = GeneratedPdf(uri = newFile.toUri(), temp.sizeInBytes, temp.pageCount) + _pdfUiState.update { + it.copy(generatedPdf = GeneratedPdf( + uri = newFile.toUri(), tempPdf.sizeInBytes, tempPdf.pageCount) + ) + } } - return _generatedPdf.value + return _pdfUiState.value.generatedPdf + } + + fun markFileSaved(uri: Uri) { + _pdfUiState.update { it.copy(savedFileUri = uri) } } } @@ -250,11 +282,13 @@ data class GeneratedPdf( val pageCount: Int, ) +// TODO Move somewhere else: ViewModel should not depend on that data class PdfGenerationActions( val startGeneration: () -> Unit, val cancelGeneration: () -> Unit, val setFilename: (String) -> Unit, - val generatedPdfFlow: StateFlow, + val uiStateFlow: StateFlow,// TODO is it ok to have that here? val sharePdf: () -> Unit, - val savePdf: () -> Unit + val savePdf: () -> Unit, + val openPdf: () -> Unit, ) diff --git a/app/src/main/java/org/mydomain/myscan/ui/UiState.kt b/app/src/main/java/org/mydomain/myscan/ui/UiState.kt new file mode 100644 index 0000000..9731263 --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/ui/UiState.kt @@ -0,0 +1,26 @@ +/* + * 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 . + */ +package org.mydomain.myscan.ui + +import android.net.Uri +import org.mydomain.myscan.GeneratedPdf + +data class PdfGenerationUiState( + val isGenerating: Boolean = false, + val generatedPdf: GeneratedPdf? = null, + val desiredFilename: String = "", + val savedFileUri: Uri? = null, + val errorMessage: String? = null +) diff --git a/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt b/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt index 07cffde..f18a63b 100644 --- a/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt +++ b/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt @@ -67,6 +67,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable import org.mydomain.myscan.PdfGenerationActions +import org.mydomain.myscan.ui.PdfGenerationUiState import org.mydomain.myscan.ui.theme.MyScanTheme @OptIn(ExperimentalMaterial3Api::class) @@ -292,8 +293,8 @@ fun DocumentScreenPreview() { toCameraScreen = {}, pdfActions = PdfGenerationActions( {}, {}, {}, - MutableStateFlow(null), - {}, {}), + MutableStateFlow(PdfGenerationUiState()), + {}, {}, {}), onStartNew = {}, onDeleteImage = { _ -> {} } ) diff --git a/app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt new file mode 100644 index 0000000..2a120a6 --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt @@ -0,0 +1,215 @@ +/* + * 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 . + */ +package org.mydomain.myscan.view + +import android.content.Context +import android.text.format.Formatter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import org.mydomain.myscan.GeneratedPdf +import org.mydomain.myscan.PdfGenerationActions +import org.mydomain.myscan.ui.PdfGenerationUiState +import org.mydomain.myscan.ui.theme.MyScanTheme +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PdfGenerationDialogWrapper( + onDismiss: () -> Unit, + pdfActions: PdfGenerationActions, + modifier: Modifier = Modifier, +) { + var filename by remember { mutableStateOf(defaultFilename()) } + val uiState by pdfActions.uiStateFlow.collectAsState() + LaunchedEffect(Unit) { + pdfActions.setFilename(filename) + pdfActions.startGeneration() + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier + ) { + PdfGenerationBottomSheet( + filename = filename, + onFilenameChange = { + filename = it + pdfActions.setFilename(it) + }, + uiState = uiState, + onDismiss = { + pdfActions.cancelGeneration() + onDismiss() + }, + onShare = { pdfActions.sharePdf() }, + onSave = { pdfActions.savePdf() }, + onOpen = { pdfActions.openPdf() }, + ) + } +} + +// TODO Handle error in PDF generation +@Composable +fun PdfGenerationBottomSheet( + filename: String, + onFilenameChange: (String) -> Unit, + uiState: PdfGenerationUiState, + onDismiss: () -> Unit, + onShare: () -> Unit, + onSave: () -> Unit, + onOpen: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text("Generate PDF", style = MaterialTheme.typography.headlineSmall) + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = filename, + onValueChange = onFilenameChange, + label = { Text("Filename") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + + val pdf = uiState.generatedPdf + if (uiState.isGenerating) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else if (pdf != null) { + val context = LocalContext.current + Text("${pdf.pageCount} pages · ${formatFileSize(pdf.sizeInBytes, context)}") + } + + Spacer(Modifier.height(24.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) { Text("Close") } + + OutlinedButton( + onClick = onShare, + enabled = pdf != null, + modifier = Modifier.weight(1f) + ) { Text("Share") } + + OutlinedButton( + onClick = onSave, + enabled = pdf != null, + modifier = Modifier.weight(1f) + ) { Text("Save") } + } + if (uiState.savedFileUri != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "PDF saved to Downloads", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onOpen) { + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Open") + } + } + + } +} + +fun defaultFilename(): String { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + return "scan_$timestamp.pdf" +} + +fun formatFileSize(sizeInBytes: Long?, context: Context): String { + return if (sizeInBytes == null) "Unknown size" + else Formatter.formatShortFileSize(context, sizeInBytes) +} + +@Preview(showBackground = true) +@Composable +fun PreviewPdfGenerationDialogDuringGeneration() { + MyScanTheme { + PdfGenerationBottomSheet( + filename = "scan_20250702_174042.pdf", + uiState = PdfGenerationUiState(isGenerating = true), + onFilenameChange = {}, + onDismiss = {}, + onShare = {}, + onSave = {}, + onOpen = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewPdfGenerationDialogAfterGeneration() { + MyScanTheme { + PdfGenerationBottomSheet( + filename = "scan_20250702_174042.pdf", + uiState = PdfGenerationUiState( + isGenerating = false, + generatedPdf = GeneratedPdf("file://fake.pdf".toUri(), 42897L, 3) + ), + onFilenameChange = {}, + onDismiss = {}, + onShare = {}, + onSave = {}, + onOpen = {}, + ) + } +} diff --git a/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt deleted file mode 100644 index 9579f8d..0000000 --- a/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt +++ /dev/null @@ -1,167 +0,0 @@ -/* - * 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 . - */ -package org.mydomain.myscan.view - -import android.content.Context -import android.text.format.Formatter -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import org.mydomain.myscan.GeneratedPdf -import org.mydomain.myscan.PdfGenerationActions -import org.mydomain.myscan.ui.theme.MyScanTheme -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Composable -fun PdfGenerationDialogWrapper( - onDismiss: () -> Unit, - pdfActions: PdfGenerationActions, -) { - var filename by remember { mutableStateOf(defaultFilename()) } - val generatedPdf by pdfActions.generatedPdfFlow.collectAsState() - LaunchedEffect(Unit) { - pdfActions.setFilename(filename) - pdfActions.startGeneration() - } - - PdfGenerationDialog( - filename = filename, - onFilenameChange = { - filename = it - pdfActions.setFilename(it) - }, - pdf = generatedPdf, - onDismiss = { - pdfActions.cancelGeneration() - onDismiss() - }, - onShare = { pdfActions.sharePdf() }, - onSave = { pdfActions.savePdf() }, - ) -} - -// TODO Handle error in PDF generation -@Composable -fun PdfGenerationDialog( - filename: String, - onFilenameChange: (String) -> Unit, - pdf: GeneratedPdf?, - onDismiss: () -> Unit, - onShare: () -> Unit, - onSave: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Generate PDF") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = filename, - onValueChange = onFilenameChange, - label = { Text("Filename") }, - modifier = Modifier.fillMaxWidth() - ) - if (pdf == null) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp - ) - Spacer(Modifier.width(8.dp)) - Text("Generating PDF…") - } - } else { - val context = LocalContext.current - Text("${pdf.pageCount} pages – ${formatFileSize(pdf.sizeInBytes, context)}") - } - } - }, - confirmButton = { - if (pdf != null) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = onShare) { Text("Share") } - TextButton(onClick = onSave) { Text("Save") } - } - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Close") } - } - ) -} - -fun defaultFilename(): String { - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) - return "scan_$timestamp.pdf" -} - -fun formatFileSize(sizeInBytes: Long?, context: Context): String { - return if (sizeInBytes == null) "Unknown size" - else Formatter.formatShortFileSize(context, sizeInBytes) -} - -@Preview -@Composable -fun PreviewPdfGenerationDialogDuringGeneration() { - MyScanTheme { - PdfGenerationDialog( - filename = "scan_20250702_174042.pdf", - pdf = null, - onFilenameChange = {}, - onDismiss = {}, - onShare = {}, - onSave = {}, - ) - } -} - -@Preview -@Composable -fun PreviewPdfGenerationDialogAfterGeneration() { - MyScanTheme { - PdfGenerationDialog( - filename = "scan_20250702_174042.pdf", - pdf = GeneratedPdf("file://fake.pdf".toUri(), 42897L, 3), - onFilenameChange = {}, - onDismiss = {}, - onShare = {}, - onSave = {}, - ) - } -} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index ac862b7..a4dae74 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,4 +3,7 @@ +