New dialog for PDF generation
This commit is contained in:
@@ -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,
|
||||
"${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"))
|
||||
private fun sharePdf(fileUri: Uri) {
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
this,
|
||||
"${applicationContext.packageName}.fileprovider",
|
||||
fileUri.toFile()
|
||||
)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/pdf"
|
||||
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 {
|
||||
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()
|
||||
private fun savePdf(fileUri: Uri) {
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = { _ -> {} }
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user