Move Save & Share to a new Export screen

This commit is contained in:
Pierre-Yves Nicolas
2025-08-29 15:33:58 +02:00
parent 7b125b06ba
commit 76a9417259
12 changed files with 226 additions and 218 deletions

View File

@@ -41,6 +41,7 @@ import org.fairscan.app.ui.theme.MyScanTheme
import org.fairscan.app.view.AboutScreen import org.fairscan.app.view.AboutScreen
import org.fairscan.app.view.CameraScreen import org.fairscan.app.view.CameraScreen
import org.fairscan.app.view.DocumentScreen import org.fairscan.app.view.DocumentScreen
import org.fairscan.app.view.ExportScreenWrapper
import org.fairscan.app.view.HomeScreen import org.fairscan.app.view.HomeScreen
import org.fairscan.app.view.LibrariesScreen import org.fairscan.app.view.LibrariesScreen
import org.opencv.android.OpenCVLoader import org.opencv.android.OpenCVLoader
@@ -67,6 +68,7 @@ class MainActivity : ComponentActivity() {
toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) }, toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) },
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) }, toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) }, toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) }, toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) }, toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) },
back = { viewModel.navigateBack() } back = { viewModel.navigateBack() }
@@ -97,21 +99,26 @@ class MainActivity : ComponentActivity() {
DocumentScreen ( DocumentScreen (
document = document, document = document,
initialPage = screen.initialPage, initialPage = screen.initialPage,
navigation = navigation,
onDeleteImage = { id -> viewModel.deletePage(id) },
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) }
)
}
is Screen.Main.Export -> {
ExportScreenWrapper(
navigation = navigation, navigation = navigation,
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(
startGeneration = viewModel::startPdfGeneration, startGeneration = viewModel::startPdfGeneration,
cancelGeneration = viewModel::cancelPdfGeneration,
setFilename = viewModel::setFilename, setFilename = viewModel::setFilename,
uiStateFlow = viewModel.pdfUiState, uiStateFlow = viewModel.pdfUiState,
sharePdf = { sharePdf(viewModel.getFinalPdf()) }, sharePdf = { sharePdf(viewModel.getFinalPdf(), viewModel) },
savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) }, savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) },
openPdf = { openPdf(viewModel.pdfUiState.value.savedFileUri) } openPdf = { openPdf(viewModel.pdfUiState.value.savedFileUri) }
), ),
onStartNew = { onCloseScan = {
viewModel.startNewDocument() viewModel.startNewDocument()
viewModel.navigateTo(Screen.Main.Home) }, viewModel.navigateTo(Screen.Main.Home)
onDeleteImage = { id -> viewModel.deletePage(id) }, },
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) }
) )
} }
is Screen.Overlay.About -> { is Screen.Overlay.About -> {
@@ -125,9 +132,10 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun sharePdf(generatedPdf: GeneratedPdf?) { private fun sharePdf(generatedPdf: GeneratedPdf?, viewModel: MainViewModel) {
if (generatedPdf == null) if (generatedPdf == null)
return return
viewModel.setPdfAsShared()
val file = generatedPdf.file val file = generatedPdf.file
val authority = "${applicationContext.packageName}.fileprovider" val authority = "${applicationContext.packageName}.fileprovider"
val fileUri = FileProvider.getUriForFile(this, authority, file) val fileUri = FileProvider.getUriForFile(this, authority, file)

View File

@@ -268,11 +268,7 @@ class MainViewModel(
} }
fun startPdfGeneration() { fun startPdfGeneration() {
val currentState = _pdfUiState.value cancelPdfGeneration()
if (currentState.isGenerating || currentState.generatedPdf != null) return
_pdfUiState.update { it.copy(isGenerating = true, errorMessage = null) }
generationJob = viewModelScope.launch { generationJob = viewModelScope.launch {
try { try {
val result = generatePdf() val result = generatePdf()
@@ -299,6 +295,10 @@ class MainViewModel(
_pdfUiState.value = PdfGenerationUiState() _pdfUiState.value = PdfGenerationUiState()
} }
fun setPdfAsShared() {
_pdfUiState.update { it.copy(hasSharedPdf = true) }
}
fun getFinalPdf(): GeneratedPdf? { fun getFinalPdf(): GeneratedPdf? {
val tempPdf = _pdfUiState.value.generatedPdf ?: return null val tempPdf = _pdfUiState.value.generatedPdf ?: return null
val tempFile = tempPdf.file val tempFile = tempPdf.file
@@ -374,7 +374,6 @@ data class GeneratedPdf(
// TODO Move somewhere else: ViewModel should not depend on that // 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 setFilename: (String) -> Unit, val setFilename: (String) -> Unit,
val uiStateFlow: StateFlow<PdfGenerationUiState>,// TODO is it ok to have that here? val uiStateFlow: StateFlow<PdfGenerationUiState>,// TODO is it ok to have that here?
val sharePdf: () -> Unit, val sharePdf: () -> Unit,

View File

@@ -19,6 +19,7 @@ sealed class Screen {
object Home : Main() object Home : Main()
object Camera : Main() object Camera : Main()
data class Document(val initialPage: Int = 0) : Main() data class Document(val initialPage: Int = 0) : Main()
object Export : Main()
} }
sealed class Overlay : Screen() { sealed class Overlay : Screen() {
object About : Overlay() object About : Overlay()
@@ -30,6 +31,7 @@ data class Navigation(
val toHomeScreen: () -> Unit, val toHomeScreen: () -> Unit,
val toCameraScreen: () -> Unit, val toCameraScreen: () -> Unit,
val toDocumentScreen: () -> Unit, val toDocumentScreen: () -> Unit,
val toExportScreen: () -> Unit,
val toAboutScreen: () -> Unit, val toAboutScreen: () -> Unit,
val toLibrariesScreen: () -> Unit, val toLibrariesScreen: () -> Unit,
val back: () -> Unit, val back: () -> Unit,
@@ -57,6 +59,7 @@ data class NavigationState private constructor(val stack: List<Screen>) {
is Screen.Main.Home -> this // Back handled by system is Screen.Main.Home -> this // Back handled by system
is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home)) is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home))
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera)) is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Document()))
is Screen.Overlay -> copy(stack = stack.dropLast(1)) is Screen.Overlay -> copy(stack = stack.dropLast(1))
} }
} }

View File

@@ -24,8 +24,11 @@ data class PdfGenerationUiState(
val desiredFilename: String = "", val desiredFilename: String = "",
val savedFileUri: Uri? = null, val savedFileUri: Uri? = null,
val saveDirectoryName: String? = null, val saveDirectoryName: String? = null,
val errorMessage: String? = null val hasSharedPdf: Boolean = false,
) val errorMessage: String? = null,
) {
val hasSavedOrSharedPdf get() = savedFileUri != null || hasSharedPdf
}
data class RecentDocumentUiState( data class RecentDocumentUiState(
val file: File, val file: File,

View File

@@ -0,0 +1,56 @@
/*
* 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.fairscan.app.view
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import org.fairscan.app.R
@Composable
fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>, title: String) {
ConfirmationDialog(title, stringResource(R.string.new_document_warning), showDialog, onConfirm)
}
@Composable
fun ConfirmationDialog(
title: String,
message: String,
showDialog: MutableState<Boolean>,
onConfirm: () -> Unit,
) {
AlertDialog(
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = {
showDialog.value = false
onConfirm()
}) {
Text(stringResource(R.string.yes), fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDialog.value = false }) {
Text(stringResource(R.string.cancel), fontWeight = FontWeight.Bold)
}
},
onDismissRequest = { showDialog.value = false },
)
}

View File

@@ -32,18 +32,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.RotateLeft import androidx.compose.material.icons.automirrored.filled.RotateLeft
import androidx.compose.material.icons.automirrored.filled.RotateRight import androidx.compose.material.icons.automirrored.filled.RotateRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -53,16 +49,12 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.fairscan.app.Navigation import org.fairscan.app.Navigation
import org.fairscan.app.PdfGenerationActions
import org.fairscan.app.R import org.fairscan.app.R
import org.fairscan.app.ui.PdfGenerationUiState
import org.fairscan.app.ui.theme.MyScanTheme import org.fairscan.app.ui.theme.MyScanTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -71,15 +63,11 @@ fun DocumentScreen(
document: DocumentUiModel, document: DocumentUiModel,
initialPage: Int, initialPage: Int,
navigation: Navigation, navigation: Navigation,
pdfActions: PdfGenerationActions,
onStartNew: () -> Unit,
onDeleteImage: (String) -> Unit, onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit, onRotateImage: (String, Boolean) -> Unit,
) { ) {
// TODO Check how often images are loaded // TODO Check how often images are loaded
val showNewDocDialog = rememberSaveable { mutableStateOf(false) }
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) } val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
val showPdfDialog = rememberSaveable { mutableStateOf(false) }
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) } val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
if (currentPageIndex.intValue >= document.pageCount()) { if (currentPageIndex.intValue >= document.pageCount()) {
currentPageIndex.intValue = document.pageCount() - 1 currentPageIndex.intValue = document.pageCount() - 1
@@ -105,7 +93,7 @@ fun DocumentScreen(
), ),
onBack = navigation.back, onBack = navigation.back,
bottomBar = { bottomBar = {
BottomBar(showPdfDialog, showNewDocDialog) BottomBar(navigation)
}, },
pageListButton = { pageListButton = {
SecondaryActionButton( SecondaryActionButton(
@@ -121,9 +109,6 @@ fun DocumentScreen(
{ showDeletePageDialog.value = true }, { showDeletePageDialog.value = true },
onRotateImage, onRotateImage,
modifier) modifier)
if (showNewDocDialog.value) {
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document))
}
if (showDeletePageDialog.value) { if (showDeletePageDialog.value) {
ConfirmationDialog( ConfirmationDialog(
title = stringResource(R.string.delete_page), title = stringResource(R.string.delete_page),
@@ -131,12 +116,6 @@ fun DocumentScreen(
showDialog = showDeletePageDialog showDialog = showDeletePageDialog
) { onDeleteImage(document.pageId(currentPageIndex.intValue)) } ) { onDeleteImage(document.pageId(currentPageIndex.intValue)) }
} }
if (showPdfDialog.value) {
PdfGenerationBottomSheetWrapper(
onDismiss = { showPdfDialog.value = false },
pdfActions = pdfActions,
)
}
} }
} }
@@ -226,8 +205,7 @@ fun RotationButtons(
@Composable @Composable
private fun BottomBar( private fun BottomBar(
showPdfDialog: MutableState<Boolean>, navigation: Navigation,
showNewDocDialog: MutableState<Boolean>,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -235,52 +213,13 @@ private fun BottomBar(
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
MainActionButton( MainActionButton(
onClick = { showPdfDialog.value = true }, onClick = navigation.toExportScreen,
icon = Icons.Default.PictureAsPdf, icon = Icons.Default.PictureAsPdf,
text = stringResource(R.string.export_pdf), text = stringResource(R.string.export_pdf),
) )
Spacer(modifier = Modifier.width(8.dp))
SecondaryActionButton(
icon = Icons.Default.Close,
contentDescription = stringResource(R.string.close_document),
onClick = { showNewDocDialog.value = true },
modifier = Modifier.padding(vertical = 8.dp)
)
} }
} }
@Composable
fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>, title: String) {
ConfirmationDialog(title, stringResource(R.string.new_document_warning), showDialog, onConfirm)
}
@Composable
private fun ConfirmationDialog(
title: String,
message: String,
showDialog: MutableState<Boolean>,
onConfirm: () -> Unit,
) {
AlertDialog(
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = {
showDialog.value = false
onConfirm()
}) {
Text(stringResource(R.string.yes), fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDialog.value = false }) {
Text(stringResource(R.string.cancel), fontWeight = FontWeight.Bold)
}
},
onDismissRequest = { showDialog.value = false },
)
}
@Composable @Composable
@Preview @Preview
fun DocumentScreenPreview() { fun DocumentScreenPreview() {
@@ -291,11 +230,6 @@ fun DocumentScreenPreview() {
LocalContext.current), LocalContext.current),
initialPage = 1, initialPage = 1,
navigation = dummyNavigation(), navigation = dummyNavigation(),
pdfActions = PdfGenerationActions(
{}, {}, {},
MutableStateFlow(PdfGenerationUiState()),
{}, {}, {}),
onStartNew = {},
onDeleteImage = { _ -> }, onDeleteImage = { _ -> },
onRotateImage = { _,_ -> }, onRotateImage = { _,_ -> },
) )

View File

@@ -16,33 +16,31 @@ package org.fairscan.app.view
import android.content.Context import android.content.Context
import android.text.format.Formatter import android.text.format.Formatter
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -50,16 +48,19 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import org.fairscan.app.GeneratedPdf import org.fairscan.app.GeneratedPdf
import org.fairscan.app.Navigation
import org.fairscan.app.PdfGenerationActions import org.fairscan.app.PdfGenerationActions
import org.fairscan.app.R import org.fairscan.app.R
import org.fairscan.app.ui.PdfGenerationUiState import org.fairscan.app.ui.PdfGenerationUiState
@@ -69,13 +70,15 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PdfGenerationBottomSheetWrapper( fun ExportScreenWrapper(
onDismiss: () -> Unit, navigation: Navigation,
pdfActions: PdfGenerationActions, pdfActions: PdfGenerationActions,
modifier: Modifier = Modifier, onCloseScan: () -> Unit,
) { ) {
BackHandler { navigation.back() }
val showConfirmationDialog = rememberSaveable { mutableStateOf(false) }
val filename = remember { mutableStateOf(defaultFilename()) } val filename = remember { mutableStateOf(defaultFilename()) }
val uiState by pdfActions.uiStateFlow.collectAsState() val uiState by pdfActions.uiStateFlow.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -83,11 +86,6 @@ fun PdfGenerationBottomSheetWrapper(
pdfActions.startGeneration() pdfActions.startGeneration()
} }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
LaunchedEffect(Unit) {
sheetState.expand()
}
val onFilenameChange = { newName:String -> val onFilenameChange = { newName:String ->
filename.value = newName filename.value = newName
pdfActions.setFilename(newName) pdfActions.setFilename(newName)
@@ -99,19 +97,11 @@ fun PdfGenerationBottomSheetWrapper(
} }
} }
ModalBottomSheet( ExportScreen(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier.navigationBarsPadding()
) {
PdfGenerationBottomSheet(
filename = filename, filename = filename,
onFilenameChange = onFilenameChange, onFilenameChange = onFilenameChange,
uiState = uiState, uiState = uiState,
onDismiss = { navigation = navigation,
pdfActions.cancelGeneration()
onDismiss()
},
onShare = { onShare = {
ensureCorrectFileName() ensureCorrectFileName()
pdfActions.sharePdf() pdfActions.sharePdf()
@@ -121,81 +111,88 @@ fun PdfGenerationBottomSheetWrapper(
pdfActions.savePdf() pdfActions.savePdf()
}, },
onOpen = { pdfActions.openPdf() }, onOpen = { pdfActions.openPdf() },
onCloseScan = {
if (uiState.hasSavedOrSharedPdf)
onCloseScan()
else
showConfirmationDialog.value = true
},
) )
if (showConfirmationDialog.value) {
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.end_scan))
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PdfGenerationBottomSheet( fun ExportScreen(
filename: MutableState<String>, filename: MutableState<String>,
onFilenameChange: (String) -> Unit, onFilenameChange: (String) -> Unit,
uiState: PdfGenerationUiState, uiState: PdfGenerationUiState,
onDismiss: () -> Unit, navigation: Navigation,
onShare: () -> Unit, onShare: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onOpen: () -> Unit, onOpen: () -> Unit,
onCloseScan: () -> Unit,
) { ) {
Column() { Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.export_pdf)) },
navigationIcon = { BackButton(navigation.back) },
actions = {
AboutScreenNavButton(onClick = navigation.toAboutScreen)
}
)
}
) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .padding(innerPadding)
.padding(top = 0.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) .padding(16.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Row (verticalAlignment = Alignment.CenterVertically) {
Row {
Icon(
Icons.Default.PictureAsPdf, contentDescription = "PDF",
modifier = Modifier
.size(34.dp)
.padding(end = 8.dp)
)
Text(
stringResource(R.string.export_pdf),
style = MaterialTheme.typography.headlineSmall
)
}
CloseButton(onDismiss)
}
Spacer(Modifier.height(16.dp))
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
OutlinedTextField( OutlinedTextField(
value = filename.value, value = filename.value,
onValueChange = onFilenameChange, onValueChange = onFilenameChange,
label = { Text(stringResource(R.string.filename)) }, label = { Text(stringResource(R.string.filename)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
trailingIcon = { trailingIcon = {
if (filename.value.isNotEmpty()) { if (filename.value.isNotEmpty()) {
IconButton(onClick = { IconButton(onClick = {
filename.value = "" filename.value = ""
focusRequester.requestFocus() focusRequester.requestFocus()
}) { }) {
Icon(Icons.Default.Clear, contentDescription = "Effacer") Icon(Icons.Default.Clear, stringResource(R.string.clear_text))
} }
} }
}, },
) )
Spacer(Modifier.height(8.dp))
val pdf = uiState.generatedPdf val pdf = uiState.generatedPdf
// PDF infos
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (uiState.isGenerating) { if (uiState.isGenerating) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) Text(stringResource(R.string.creating_pdf), fontStyle = FontStyle.Italic)
} else if (pdf != null) { } else if (pdf != null) {
val context = LocalContext.current val context = LocalContext.current
val formattedFileSize = formatFileSize(pdf.sizeInBytes, context) val formattedFileSize = formatFileSize(pdf.sizeInBytes, context)
Text(text = pageCountText(pdf.pageCount))
Text( Text(
text = "${pageCountText(pdf.pageCount)} · $formattedFileSize", text = stringResource(R.string.file_size, formattedFileSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
) )
} }
Spacer(Modifier.height(24.dp))
MainActions(pdf, onShare, onSave)
} }
if (uiState.saveDirectoryName != null) { if (uiState.saveDirectoryName != null) {
@@ -204,6 +201,25 @@ fun PdfGenerationBottomSheet(
if (uiState.errorMessage != null) { if (uiState.errorMessage != null) {
ErrorBar(uiState.errorMessage) ErrorBar(uiState.errorMessage)
} }
Spacer(Modifier.weight(1f)) // push buttons down
// Export actions
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
MainActions(pdf, onShare, onSave)
OutlinedButton(
onClick = onCloseScan,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Done, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.end_scan))
}
}
}
} }
} }
@@ -271,21 +287,6 @@ private fun ErrorBar(errorMessage: String) {
) )
} }
@Composable
private fun CloseButton(onDismiss: () -> Unit) {
Box(Modifier.fillMaxWidth()) {
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close)
)
}
}
}
fun defaultFilename(): String { fun defaultFilename(): String {
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date()) val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
return "Scan $timestamp" return "Scan $timestamp"
@@ -296,57 +297,48 @@ fun formatFileSize(sizeInBytes: Long?, context: Context): String {
else Formatter.formatShortFileSize(context, sizeInBytes) else Formatter.formatShortFileSize(context, sizeInBytes)
} }
@Preview(showBackground = true) @Preview
@Composable @Composable
fun PreviewPdfGenerationDialogDuringGeneration() { fun PreviewExportScreenDuringGeneration() {
PreviewToCustomize( ExportPreviewToCustomize(
uiState = PdfGenerationUiState(isGenerating = true) uiState = PdfGenerationUiState(isGenerating = true)
) )
} }
@Preview(showBackground = true) @Preview
@Composable @Composable
fun PreviewPdfGenerationDialogAfterGeneration() { fun PreviewExportScreenAfterSave() {
PreviewToCustomize(
uiState = PdfGenerationUiState(
generatedPdf = GeneratedPdf(File("fake.pdf"), 442897L, 1)
)
)
}
@Preview(showBackground = true)
@Composable
fun PreviewPdfGenerationDialogAfterSave() {
val file = File("fake.pdf") val file = File("fake.pdf")
PreviewToCustomize( ExportPreviewToCustomize(
uiState = PdfGenerationUiState( uiState = PdfGenerationUiState(
generatedPdf = GeneratedPdf(file, 442897L, 3), generatedPdf = GeneratedPdf(file, 442897L, 3),
savedFileUri = file.toUri() savedFileUri = file.toUri(),
) saveDirectoryName = "Downloads",
),
) )
} }
@Preview(showBackground = true) @Preview
@Composable @Composable
fun PreviewPdfGenerationDialogWithError() { fun ExportScreenPreviewWithError() {
PreviewToCustomize( ExportPreviewToCustomize(
uiState = PdfGenerationUiState( PdfGenerationUiState(errorMessage = "PDF generation failed")
errorMessage = "PDF generation failed"
)
) )
} }
@Composable @Composable
fun PreviewToCustomize(uiState: PdfGenerationUiState) { fun ExportPreviewToCustomize(uiState: PdfGenerationUiState) {
MyScanTheme { MyScanTheme {
PdfGenerationBottomSheet( ExportScreen(
filename = remember { mutableStateOf("Scan 2025-07-02 17.40.42.pdf") }, filename = remember { mutableStateOf("Scan 2025-07-02 17.40.42") },
onFilenameChange = {_->},
navigation = dummyNavigation(),
uiState = uiState, uiState = uiState,
onFilenameChange = {},
onDismiss = {},
onShare = {}, onShare = {},
onSave = {}, onSave = {},
onOpen = {}, onOpen = {},
onCloseScan = {},
) )
} }
} }

View File

@@ -19,7 +19,7 @@ import android.graphics.BitmapFactory
import org.fairscan.app.Navigation import org.fairscan.app.Navigation
fun dummyNavigation(): Navigation { fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {}) return Navigation({}, {}, {}, {}, {}, {}, {})
} }
fun fakeDocument(pageIds: List<String>, context: Context): DocumentUiModel { fun fakeDocument(pageIds: List<String>, context: Context): DocumentUiModel {

View File

@@ -10,16 +10,19 @@
<string name="clear_text">Text löschen</string> <string name="clear_text">Text löschen</string>
<string name="close">Schließen</string> <string name="close">Schließen</string>
<string name="close_document">Dokument schließen</string> <string name="close_document">Dokument schließen</string>
<string name="creating_pdf">PDF wird erstellt…</string>
<string name="current_document">Aktuelles Dokument</string> <string name="current_document">Aktuelles Dokument</string>
<string name="delete_page">Seite löschen</string> <string name="delete_page">Seite löschen</string>
<string name="delete_page_warning">Möchten Sie diese Seite löschen?</string> <string name="delete_page_warning">Möchten Sie diese Seite löschen?</string>
<string name="document">Dokument</string> <string name="document">Dokument</string>
<string name="end_scan">Scan beenden</string>
<string name="error">Fehler: %1$s</string> <string name="error">Fehler: %1$s</string>
<string name="error_no_document">Kein Dokument erkannt</string> <string name="error_no_document">Kein Dokument erkannt</string>
<string name="error_no_pdf_app">Keine App zum Öffnen von PDF gefunden</string> <string name="error_no_pdf_app">Keine App zum Öffnen von PDF gefunden</string>
<string name="error_save">PDF konnte nicht gespeichert werden</string> <string name="error_save">PDF konnte nicht gespeichert werden</string>
<string name="export_pdf">PDF exportieren</string> <string name="export_pdf">PDF exportieren</string>
<string name="filename">Dateiname</string> <string name="filename">Dateiname</string>
<string name="file_size">Dateigröße: %1$s</string>
<string name="grant_permission">Berechtigung erteilen</string> <string name="grant_permission">Berechtigung erteilen</string>
<string name="last_saved_documents">Zuletzt gespeicherte Dokumente</string> <string name="last_saved_documents">Zuletzt gespeicherte Dokumente</string>
<string name="libraries">Bibliotheken</string> <string name="libraries">Bibliotheken</string>
@@ -28,7 +31,7 @@
<string name="license">Lizenz</string> <string name="license">Lizenz</string>
<string name="licensed_under">Diese Anwendung ist unter der GNU General Public License v3.0 lizenziert.</string> <string name="licensed_under">Diese Anwendung ist unter der GNU General Public License v3.0 lizenziert.</string>
<string name="new_document">Neues Dokument</string> <string name="new_document">Neues Dokument</string>
<string name="new_document_warning">Das aktuelle Dokument geht verloren, wenn Sie es nicht gespeichert haben. Möchten Sie fortfahren?</string> <string name="new_document_warning">Das aktuelle Dokument geht verloren. Möchten Sie fortfahren?</string>
<string name="open">Öffnen</string> <string name="open">Öffnen</string>
<string name="open_pdf">PDF öffnen</string> <string name="open_pdf">PDF öffnen</string>
<string name="pdf_saved_to">PDF gespeichert unter %1$s</string> <string name="pdf_saved_to">PDF gespeichert unter %1$s</string>

View File

@@ -9,16 +9,19 @@
<string name="clear_text">Effacer le text</string> <string name="clear_text">Effacer le text</string>
<string name="close">Fermer</string> <string name="close">Fermer</string>
<string name="close_document">Fermer le document</string> <string name="close_document">Fermer le document</string>
<string name="creating_pdf">Création du PDF…</string>
<string name="current_document">Document en cours</string> <string name="current_document">Document en cours</string>
<string name="delete_page">Supprimer la page</string> <string name="delete_page">Supprimer la page</string>
<string name="delete_page_warning">Voulez-vous supprimer cette page ?</string> <string name="delete_page_warning">Voulez-vous supprimer cette page ?</string>
<string name="document">Document</string> <string name="document">Document</string>
<string name="end_scan">Terminer le scan</string>
<string name="error">Erreur : %1$s</string> <string name="error">Erreur : %1$s</string>
<string name="error_no_document">Aucun document détecté</string> <string name="error_no_document">Aucun document détecté</string>
<string name="error_no_pdf_app">Aucune application trouvée pour ouvrir un PDF</string> <string name="error_no_pdf_app">Aucune application trouvée pour ouvrir un PDF</string>
<string name="error_save">Échec de l\'enregistrement du PDF</string> <string name="error_save">Échec de l\'enregistrement du PDF</string>
<string name="export_pdf">Exporter en PDF</string> <string name="export_pdf">Exporter en PDF</string>
<string name="filename">Nom de fichier</string> <string name="filename">Nom de fichier</string>
<string name="file_size">Taille du fichier : %1$s</string>
<string name="grant_permission">Autoriser</string> <string name="grant_permission">Autoriser</string>
<string name="last_saved_documents">Derniers documents enregistrés</string> <string name="last_saved_documents">Derniers documents enregistrés</string>
<string name="libraries">Bibliothèques</string> <string name="libraries">Bibliothèques</string>
@@ -27,7 +30,7 @@
<string name="license">Licence</string> <string name="license">Licence</string>
<string name="licensed_under">Cette application est distribuée sous licence GNU General Public License v3.0.</string> <string name="licensed_under">Cette application est distribuée sous licence GNU General Public License v3.0.</string>
<string name="new_document">Nouveau document</string> <string name="new_document">Nouveau document</string>
<string name="new_document_warning">Le document en cours sera perdu si vous ne l\'avez pas enregistré. Voulez-vous continuer ?</string> <string name="new_document_warning">Le scan en cours sera perdu. Voulez-vous continuer ?</string>
<string name="open">Ouvrir</string> <string name="open">Ouvrir</string>
<string name="open_pdf">Ouvrir le PDF</string> <string name="open_pdf">Ouvrir le PDF</string>
<string name="pdf_saved_to">PDF enregistré dans %1$s</string> <string name="pdf_saved_to">PDF enregistré dans %1$s</string>

View File

@@ -10,16 +10,19 @@
<string name="clear_text">Clear text</string> <string name="clear_text">Clear text</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="close_document">Close document</string> <string name="close_document">Close document</string>
<string name="creating_pdf">Creating PDF…</string>
<string name="current_document">Current document</string> <string name="current_document">Current document</string>
<string name="delete_page">Delete page</string> <string name="delete_page">Delete page</string>
<string name="delete_page_warning">Do you want to delete this page?</string> <string name="delete_page_warning">Do you want to delete this page?</string>
<string name="document">Document</string> <string name="document">Document</string>
<string name="end_scan">End scan</string>
<string name="error">Error: %1$s</string> <string name="error">Error: %1$s</string>
<string name="error_no_document">No document detected</string> <string name="error_no_document">No document detected</string>
<string name="error_no_pdf_app">No app found to open PDF</string> <string name="error_no_pdf_app">No app found to open PDF</string>
<string name="error_save">Failed to save PDF</string> <string name="error_save">Failed to save PDF</string>
<string name="export_pdf">Export PDF</string> <string name="export_pdf">Export PDF</string>
<string name="filename">Filename</string> <string name="filename">Filename</string>
<string name="file_size">File size: %1$s</string>
<string name="grant_permission">Grant permission</string> <string name="grant_permission">Grant permission</string>
<string name="last_saved_documents">Last saved documents</string> <string name="last_saved_documents">Last saved documents</string>
<string name="libraries">Libraries</string> <string name="libraries">Libraries</string>
@@ -28,7 +31,7 @@
<string name="license">License</string> <string name="license">License</string>
<string name="licensed_under">This application is licensed under the GNU General Public License v3.0.</string> <string name="licensed_under">This application is licensed under the GNU General Public License v3.0.</string>
<string name="new_document">New document</string> <string name="new_document">New document</string>
<string name="new_document_warning">The current document will be lost if you haven\'t saved it. Do you want to continue?</string> <string name="new_document_warning">The current scan will be lost. Do you want to continue?</string>
<string name="open">Open</string> <string name="open">Open</string>
<string name="open_pdf">Open PDF</string> <string name="open_pdf">Open PDF</string>
<string name="pdf_saved_to">PDF saved to %1$s</string> <string name="pdf_saved_to">PDF saved to %1$s</string>

View File

@@ -17,6 +17,7 @@ package org.fairscan.app
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.Screen.Main.Camera import org.fairscan.app.Screen.Main.Camera
import org.fairscan.app.Screen.Main.Document import org.fairscan.app.Screen.Main.Document
import org.fairscan.app.Screen.Main.Export
import org.fairscan.app.Screen.Main.Home import org.fairscan.app.Screen.Main.Home
import org.fairscan.app.Screen.Overlay.About import org.fairscan.app.Screen.Overlay.About
import org.fairscan.app.Screen.Overlay.Libraries import org.fairscan.app.Screen.Overlay.Libraries
@@ -36,10 +37,12 @@ class NavigationTest {
val atHome = NavigationState.initial() val atHome = NavigationState.initial()
val atCamera = atHome.navigateTo(Camera) val atCamera = atHome.navigateTo(Camera)
val atDocument = atHome.navigateTo(Document()) val atDocument = atHome.navigateTo(Document())
val atExport = atHome.navigateTo(Export)
assertThat(atHome.current).isEqualTo(Home) assertThat(atHome.current).isEqualTo(Home)
assertThat(atCamera.current).isEqualTo(Camera) assertThat(atCamera.current).isEqualTo(Camera)
assertThat(atDocument.current).isEqualTo(Document()) assertThat(atDocument.current).isEqualTo(Document())
assertThat(atExport.current).isEqualTo(Export)
assertThat(atCamera.navigateTo(Document())).isEqualTo(atDocument) assertThat(atCamera.navigateTo(Document())).isEqualTo(atDocument)
assertThat(atDocument.navigateTo(Home)).isEqualTo(atHome) assertThat(atDocument.navigateTo(Home)).isEqualTo(atHome)
@@ -48,6 +51,7 @@ class NavigationTest {
assertThat(atHome.navigateBack()).isEqualTo(atHome) assertThat(atHome.navigateBack()).isEqualTo(atHome)
assertThat(atCamera.navigateBack()).isEqualTo(atHome) assertThat(atCamera.navigateBack()).isEqualTo(atHome)
assertThat(atDocument.navigateBack()).isEqualTo(atCamera) assertThat(atDocument.navigateBack()).isEqualTo(atCamera)
assertThat(atExport.navigateBack()).isEqualTo(atDocument)
} }
@Test @Test