PDF generation: change to a BottomSheet, button to "open" the saved PDF
This commit is contained in:
@@ -14,9 +14,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.mydomain.myscan
|
package org.mydomain.myscan
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
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
|
||||||
@@ -28,6 +30,7 @@ import androidx.activity.viewModels
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -40,6 +43,8 @@ import org.mydomain.myscan.view.DocumentScreen
|
|||||||
import org.opencv.android.OpenCVLoader
|
import org.opencv.android.OpenCVLoader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private const val PDF_MIME_TYPE = "application/pdf"
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -71,9 +76,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
startGeneration = viewModel::startPdfGeneration,
|
startGeneration = viewModel::startPdfGeneration,
|
||||||
cancelGeneration = viewModel::cancelPdfGeneration,
|
cancelGeneration = viewModel::cancelPdfGeneration,
|
||||||
setFilename = viewModel::setFilename,
|
setFilename = viewModel::setFilename,
|
||||||
generatedPdfFlow = viewModel.generatedPdf,
|
uiStateFlow = viewModel.pdfUiState,
|
||||||
sharePdf = { sharePdf(viewModel.getFinalPdf()) },
|
sharePdf = { sharePdf(viewModel.getFinalPdf()) },
|
||||||
savePdf = { savePdf(viewModel.getFinalPdf()) },
|
savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) },
|
||||||
|
openPdf = { openPdf(viewModel.pdfUiState.value.savedFileUri) }
|
||||||
),
|
),
|
||||||
onStartNew = {
|
onStartNew = {
|
||||||
viewModel.startNewDocument()
|
viewModel.startNewDocument()
|
||||||
@@ -93,7 +99,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val authority = "${applicationContext.packageName}.fileprovider"
|
val authority = "${applicationContext.packageName}.fileprovider"
|
||||||
val fileUri = FileProvider.getUriForFile(this, authority, file)
|
val fileUri = FileProvider.getUriForFile(this, authority, file)
|
||||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "application/pdf"
|
type = PDF_MIME_TYPE
|
||||||
putExtra(Intent.EXTRA_STREAM, fileUri)
|
putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
@@ -107,7 +113,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
startActivity(chooser)
|
startActivity(chooser)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun savePdf(generatedPdf: GeneratedPdf?) {
|
private fun savePdf(generatedPdf: GeneratedPdf?, viewModel: MainViewModel) {
|
||||||
if (generatedPdf == null)
|
if (generatedPdf == null)
|
||||||
return
|
return
|
||||||
val appScope = CoroutineScope(Dispatchers.IO)
|
val appScope = CoroutineScope(Dispatchers.IO)
|
||||||
@@ -123,17 +129,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
val targetFile = File(downloadsDir, generatedFile.name)
|
val targetFile = File(downloadsDir, generatedFile.name)
|
||||||
// TODO Handle case where the target file already exists (choose a unique name)
|
// TODO Handle case where the target file already exists (choose a unique name)
|
||||||
generatedFile.copyTo(targetFile)
|
generatedFile.copyTo(targetFile)
|
||||||
|
viewModel.markFileSaved(targetFile.toUri())
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
// TODO Display a link to the file
|
|
||||||
Toast.makeText(context, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspendCancellableCoroutine { continuation ->
|
suspendCancellableCoroutine { continuation ->
|
||||||
MediaScannerConnection.scanFile(
|
MediaScannerConnection.scanFile(
|
||||||
context,
|
context,
|
||||||
arrayOf(targetFile.absolutePath),
|
arrayOf(targetFile.absolutePath),
|
||||||
arrayOf("application/pdf")
|
arrayOf(PDF_MIME_TYPE)
|
||||||
) { _, _ -> continuation.resume(Unit) {} }
|
) { _, _ -> continuation.resume(Unit) {} }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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() {
|
private fun initLibraries() {
|
||||||
com.tom_roush.pdfbox.android.PDFBoxResourceLoader.init(applicationContext)
|
com.tom_roush.pdfbox.android.PDFBoxResourceLoader.init(applicationContext)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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 android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -32,8 +33,10 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.mydomain.myscan.ui.PdfGenerationUiState
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -208,7 +211,9 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val _generatedPdf = MutableStateFlow<GeneratedPdf?>(null)
|
private val _generatedPdf = MutableStateFlow<GeneratedPdf?>(null)
|
||||||
val generatedPdf: StateFlow<GeneratedPdf?> = _generatedPdf
|
|
||||||
|
private val _pdfUiState = MutableStateFlow(PdfGenerationUiState())
|
||||||
|
val pdfUiState: StateFlow<PdfGenerationUiState> = _pdfUiState.asStateFlow()
|
||||||
|
|
||||||
private var generationJob: Job? = null
|
private var generationJob: Job? = null
|
||||||
private var desiredFilename: String = ""
|
private var desiredFilename: String = ""
|
||||||
@@ -218,29 +223,56 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startPdfGeneration() {
|
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 {
|
generationJob = viewModelScope.launch {
|
||||||
|
try {
|
||||||
val result = generatePdf()
|
val result = generatePdf()
|
||||||
_generatedPdf.value = result
|
_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() {
|
fun cancelPdfGeneration() {
|
||||||
generationJob?.cancel()
|
generationJob?.cancel()
|
||||||
_generatedPdf.value = null
|
_pdfUiState.value = PdfGenerationUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getFinalPdf(): GeneratedPdf? {
|
fun getFinalPdf(): GeneratedPdf? {
|
||||||
val temp = _generatedPdf.value ?: return null
|
val tempPdf = _pdfUiState.value.generatedPdf ?: return null
|
||||||
val tempFile = temp.uri.toFile()
|
val tempFile = tempPdf.uri.toFile()
|
||||||
val newFile = File(tempFile.parentFile, desiredFilename)
|
val newFile = File(tempFile.parentFile, desiredFilename)
|
||||||
if (tempFile.absolutePath != newFile.absolutePath) {
|
if (tempFile.absolutePath != newFile.absolutePath) {
|
||||||
if (newFile.exists()) newFile.delete()
|
if (newFile.exists()) newFile.delete()
|
||||||
val success = tempFile.renameTo(newFile)
|
val success = tempFile.renameTo(newFile)
|
||||||
if (!success) return null
|
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,
|
val pageCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO Move somewhere else: ViewModel should not depend on that
|
||||||
data class PdfGenerationActions(
|
data class PdfGenerationActions(
|
||||||
val startGeneration: () -> Unit,
|
val startGeneration: () -> Unit,
|
||||||
val cancelGeneration: () -> Unit,
|
val cancelGeneration: () -> Unit,
|
||||||
val setFilename: (String) -> Unit,
|
val setFilename: (String) -> Unit,
|
||||||
val generatedPdfFlow: StateFlow<GeneratedPdf?>,
|
val uiStateFlow: StateFlow<PdfGenerationUiState>,// TODO is it ok to have that here?
|
||||||
val sharePdf: () -> Unit,
|
val sharePdf: () -> Unit,
|
||||||
val savePdf: () -> Unit
|
val savePdf: () -> Unit,
|
||||||
|
val openPdf: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|||||||
26
app/src/main/java/org/mydomain/myscan/ui/UiState.kt
Normal file
26
app/src/main/java/org/mydomain/myscan/ui/UiState.kt
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -67,6 +67,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
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.PdfGenerationActions
|
||||||
|
import org.mydomain.myscan.ui.PdfGenerationUiState
|
||||||
import org.mydomain.myscan.ui.theme.MyScanTheme
|
import org.mydomain.myscan.ui.theme.MyScanTheme
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -292,8 +293,8 @@ fun DocumentScreenPreview() {
|
|||||||
toCameraScreen = {},
|
toCameraScreen = {},
|
||||||
pdfActions = PdfGenerationActions(
|
pdfActions = PdfGenerationActions(
|
||||||
{}, {}, {},
|
{}, {}, {},
|
||||||
MutableStateFlow(null),
|
MutableStateFlow(PdfGenerationUiState()),
|
||||||
{}, {}),
|
{}, {}, {}),
|
||||||
onStartNew = {},
|
onStartNew = {},
|
||||||
onDeleteImage = { _ -> {} }
|
onDeleteImage = { _ -> {} }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 <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.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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <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.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 = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,7 @@
|
|||||||
<cache-path
|
<cache-path
|
||||||
name="pdfs"
|
name="pdfs"
|
||||||
path="pdfs/" />
|
path="pdfs/" />
|
||||||
|
<external-path
|
||||||
|
name="external_files"
|
||||||
|
path="." />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
Reference in New Issue
Block a user