New dialog for PDF generation

This commit is contained in:
Pierre-Yves Nicolas
2025-07-03 17:46:16 +02:00
parent 112c44da91
commit 80bc1affb8
4 changed files with 272 additions and 75 deletions

View File

@@ -14,9 +14,9 @@
*/
package org.mydomain.myscan
import android.content.Context
import android.content.Intent
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.util.Log
@@ -25,18 +25,15 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.mydomain.myscan.ui.theme.MyScanTheme
import org.mydomain.myscan.view.CameraScreen
import org.mydomain.myscan.view.DocumentScreen
import org.opencv.android.OpenCVLoader
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
class MainActivity : ComponentActivity() {
@@ -49,7 +46,6 @@ class MainActivity : ComponentActivity() {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle()
val pageIds by viewModel.pageIds.collectAsStateWithLifecycle()
val context = LocalContext.current
MyScanTheme {
when (val screen = currentScreen) {
is Screen.Camera -> {
@@ -66,8 +62,13 @@ class MainActivity : ComponentActivity() {
initialPage = screen.initialPage,
imageLoader = { id -> viewModel.getBitmap(id) },
toCameraScreen = { viewModel.navigateTo(Screen.Camera) },
onSavePressed = savePdf(viewModel, context),
onSharePressed = sharePdf(viewModel, context),
// TODO Save and share files with the filename chosen by the user
pdfActions = PdfGenerationActions(
generatePdf = viewModel::generatePdf,
onShare = { uri -> sharePdf(uri) },
onSave = { uri -> savePdf(uri) },
onOpen = { uri -> savePdf(uri) /* TODO Open */}
),
onStartNew = {
viewModel.startNewDocument()
viewModel.navigateTo(Screen.Camera) },
@@ -79,57 +80,32 @@ class MainActivity : ComponentActivity() {
}
}
private fun sharePdf(
viewModel: MainViewModel,
context: Context
): () -> Unit = {
val outputDir = File(cacheDir, "pdfs").apply { mkdirs() }
val outputFile = File(outputDir, "scan_${System.currentTimeMillis()}.pdf")
var success = true
try {
val fileOutputStream = FileOutputStream(outputFile)
viewModel.createPdf(fileOutputStream)
} catch (_: IOException) {
Toast.makeText(context, "Failed to share PDF", Toast.LENGTH_SHORT).show()
success = false
}
if (success) {
val uri = FileProvider.getUriForFile(
context,
private fun sharePdf(fileUri: Uri) {
val fileUri = FileProvider.getUriForFile(
this,
"${applicationContext.packageName}.fileprovider",
outputFile
fileUri.toFile()
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "Share PDF"))
}
}
private fun savePdf(
viewModel: MainViewModel,
context: Context
): () -> Unit = {
try {
private fun savePdf(fileUri: Uri) {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val file = File(downloadsDir, "scan_${System.currentTimeMillis()}.pdf")
val outputStream = FileOutputStream(file)
viewModel.createPdf(outputStream)
outputStream.flush()
outputStream.close()
MediaScannerConnection.scanFile(
context, arrayOf(file.absolutePath), arrayOf("application/pdf"), null
)
Toast.makeText(context, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.e("MyScan", "Failed to save PDF", e)
Toast.makeText(context, "Failed to save PDF", Toast.LENGTH_SHORT).show()
if (!downloadsDir.exists()) {
downloadsDir.mkdirs()
}
val generatedFile = fileUri.toFile()
val targetFile = File(downloadsDir, generatedFile.name)
generatedFile.copyTo(targetFile)
MediaScannerConnection.scanFile(
this, arrayOf(targetFile.absolutePath), arrayOf("application/pdf"), null
)
Toast.makeText(this, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show()
}
private fun initLibraries() {

View File

@@ -17,7 +17,9 @@ package org.mydomain.myscan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.camera.core.ImageProxy
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -31,18 +33,25 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
class MainViewModel(
private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository,
private val pdfDir: File,
): ViewModel() {
companion object {
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return MainViewModel(ImageSegmentationService(context), ImageRepository(context.filesDir)) as T
return MainViewModel(
ImageSegmentationService(context),
ImageRepository(context.filesDir),
File(context.cacheDir, "pdfs"),
) as T
}
}
}
@@ -191,4 +200,26 @@ class MainViewModel(
.filterNotNull()
writePdfFromJpegs(jpegs, outputStream)
}
suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val pageCount = imageRepository.imageIds().size
val file = File(pdfDir,"${System.currentTimeMillis()}.pdf")
createPdf(FileOutputStream(file))
val sizeBytes = file.length()
val uri = file.toUri()
return@withContext GeneratedPdf(uri, sizeBytes, pageCount)
}
}
data class GeneratedPdf(
val uri: Uri,
val sizeInBytes: Long,
val pageCount: Int,
)
data class PdfGenerationActions(
val generatePdf: suspend () -> GeneratedPdf?,
val onShare: (Uri) -> Unit,
val onSave: (Uri) -> Unit,
val onOpen: (Uri) -> Unit
)

View File

@@ -30,9 +30,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
@@ -66,6 +65,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import org.mydomain.myscan.PdfGenerationActions
import org.mydomain.myscan.ui.theme.MyScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@@ -75,13 +75,13 @@ fun DocumentScreen(
initialPage: Int,
imageLoader: (String) -> Bitmap?,
toCameraScreen: () -> Unit,
onSavePressed: () -> Unit,
onSharePressed: () -> Unit,
pdfActions: PdfGenerationActions,
onStartNew: () -> Unit,
onDeleteImage: (String) -> Unit,
) {
// TODO Check how often images are loaded
var showDialog = rememberSaveable { mutableStateOf(false) }
val showNewDocDialog = rememberSaveable { mutableStateOf(false) }
val showPdfDialog = rememberSaveable { mutableStateOf(false) }
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
if (currentPageIndex.intValue >= pageIds.size) {
currentPageIndex.intValue = pageIds.size - 1
@@ -111,23 +111,17 @@ fun DocumentScreen(
BottomAppBar(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
actions = {
Button(onClick = onSharePressed) {
Icon(Icons.Default.Share, contentDescription = "Share")
Button(onClick = { showPdfDialog.value = true }) {
Icon(Icons.Default.PictureAsPdf, contentDescription = "Generate PDF")
Spacer(Modifier.width(8.dp))
Text("Share")
}
Spacer(modifier = Modifier.size(8.dp))
Button(onClick = onSavePressed) {
Icon(Icons.Default.Download, contentDescription = "Save")
Spacer(Modifier.width(8.dp))
Text("Save")
Text("Generate PDF")
}
},
floatingActionButton = {
MyIconButton(
icon = Icons.Default.RestartAlt,
contentDescription = "Restart",
onClick = { showDialog.value = true },
onClick = { showNewDocDialog.value = true },
modifier = Modifier.padding(vertical = 8.dp)
)
}
@@ -136,8 +130,14 @@ fun DocumentScreen(
}
) { padding ->
DocumentPreview(pageIds, imageLoader, currentPageIndex, onDeleteImage, padding)
if (showDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showDialog)
if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog)
}
if (showPdfDialog.value) {
PdfGenerationDialogWrapper(
onDismiss = { showPdfDialog.value = false },
pdfActions = pdfActions,
)
}
}
}
@@ -289,8 +289,7 @@ fun DocumentScreenPreview() {
}
},
toCameraScreen = {},
onSavePressed = {},
onSharePressed = {},
pdfActions = PdfGenerationActions({ null }, {}, {}, {}),
onStartNew = {},
onDeleteImage = { _ -> {} }
)

View File

@@ -0,0 +1,191 @@
/*
* 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.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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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()) }
var isGenerating by remember { mutableStateOf(true) }
var pdf by remember { mutableStateOf<GeneratedPdf?>(null) }
val coroutineScope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(Unit) {
job = coroutineScope.launch {
try {
val result = pdfActions.generatePdf()
pdf = result
isGenerating = false
} catch (_: CancellationException) {
// Cancelled
} catch (_: Exception) {
// Error
isGenerating = false
}
}
}
PdfGenerationDialog(
filename = filename,
onFilenameChange = { filename = it },
isGenerating = isGenerating,
pdf = pdf,
onDismiss = {
job?.cancel()
onDismiss()
},
onShare = { pdf?.uri?.let(pdfActions.onShare) },
onSave = { pdf?.uri?.let(pdfActions.onSave) },
onOpen = { pdf?.uri?.let(pdfActions.onOpen) }
)
}
@Composable
fun PdfGenerationDialog(
filename: String,
onFilenameChange: (String) -> Unit,
isGenerating: Boolean,
pdf: GeneratedPdf?,
onDismiss: () -> Unit,
onShare: () -> Unit,
onSave: () -> Unit,
onOpen: () -> 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 (isGenerating) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
Text("Generating PDF…")
}
} else if (pdf != null) {
val context = LocalContext.current
Text("${pdf.pageCount} pages ${formatFileSize(pdf.sizeInBytes, context)}")
}
}
},
// TODO 4 buttons (counting dismissButton): that's too many
confirmButton = {
if (!isGenerating && pdf != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onShare) { Text("Share") }
TextButton(onClick = onSave) { Text("Save") }
TextButton(onClick = onOpen) { Text("Open") }
}
}
},
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",
isGenerating = true,
pdf = null,
onFilenameChange = {},
onDismiss = {},
onShare = {},
onSave = {},
onOpen = {}
)
}
}
@Preview
@Composable
fun PreviewPdfGenerationDialogAfterGeneration() {
MyScanTheme {
PdfGenerationDialog(
filename = "scan_20250702_174042.pdf",
isGenerating = false,
pdf = GeneratedPdf("file://fake.pdf".toUri(), 42897L, 3),
onFilenameChange = {},
onDismiss = {},
onShare = {},
onSave = {},
onOpen = {}
)
}
}