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

View File

@@ -17,7 +17,9 @@ package org.mydomain.myscan
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -31,18 +33,25 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream import java.io.OutputStream
class MainViewModel( class MainViewModel(
private val imageSegmentationService: ImageSegmentationService, private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val pdfDir: File,
): ViewModel() { ): ViewModel() {
companion object { companion object {
fun getFactory(context: Context) = object : ViewModelProvider.Factory { fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T { 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() .filterNotNull()
writePdfFromJpegs(jpegs, outputStream) 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add 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.RestartAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
@@ -66,6 +65,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable import net.engawapg.lib.zoomable.zoomable
import org.mydomain.myscan.PdfGenerationActions
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -75,13 +75,13 @@ fun DocumentScreen(
initialPage: Int, initialPage: Int,
imageLoader: (String) -> Bitmap?, imageLoader: (String) -> Bitmap?,
toCameraScreen: () -> Unit, toCameraScreen: () -> Unit,
onSavePressed: () -> Unit, pdfActions: PdfGenerationActions,
onSharePressed: () -> Unit,
onStartNew: () -> Unit, onStartNew: () -> Unit,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
) { ) {
// TODO Check how often images are loaded // 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) } val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
if (currentPageIndex.intValue >= pageIds.size) { if (currentPageIndex.intValue >= pageIds.size) {
currentPageIndex.intValue = pageIds.size - 1 currentPageIndex.intValue = pageIds.size - 1
@@ -111,23 +111,17 @@ fun DocumentScreen(
BottomAppBar( BottomAppBar(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
actions = { actions = {
Button(onClick = onSharePressed) { Button(onClick = { showPdfDialog.value = true }) {
Icon(Icons.Default.Share, contentDescription = "Share") Icon(Icons.Default.PictureAsPdf, contentDescription = "Generate PDF")
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Share") Text("Generate PDF")
}
Spacer(modifier = Modifier.size(8.dp))
Button(onClick = onSavePressed) {
Icon(Icons.Default.Download, contentDescription = "Save")
Spacer(Modifier.width(8.dp))
Text("Save")
} }
}, },
floatingActionButton = { floatingActionButton = {
MyIconButton( MyIconButton(
icon = Icons.Default.RestartAlt, icon = Icons.Default.RestartAlt,
contentDescription = "Restart", contentDescription = "Restart",
onClick = { showDialog.value = true }, onClick = { showNewDocDialog.value = true },
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
) )
} }
@@ -136,8 +130,14 @@ fun DocumentScreen(
} }
) { padding -> ) { padding ->
DocumentPreview(pageIds, imageLoader, currentPageIndex, onDeleteImage, padding) DocumentPreview(pageIds, imageLoader, currentPageIndex, onDeleteImage, padding)
if (showDialog.value) { if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showDialog) NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog)
}
if (showPdfDialog.value) {
PdfGenerationDialogWrapper(
onDismiss = { showPdfDialog.value = false },
pdfActions = pdfActions,
)
} }
} }
} }
@@ -289,8 +289,7 @@ fun DocumentScreenPreview() {
} }
}, },
toCameraScreen = {}, toCameraScreen = {},
onSavePressed = {}, pdfActions = PdfGenerationActions({ null }, {}, {}, {}),
onSharePressed = {},
onStartNew = {}, onStartNew = {},
onDeleteImage = { _ -> {} } 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 = {}
)
}
}