From 63bc93ebd2a78109e0ea1df5c8e7bdc5e02534a3 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Sun, 27 Sep 2026 17:27:14 +0200 Subject: [PATCH] MEDIA-005 Export fertig --- .../java/de/medapp/wunddoku/pdf/PdfSharer.kt | 72 ++++++++++++++ .../edit/DocumentationEditScreen.kt | 82 ++++++++++++++-- .../edit/DocumentationEditViewModel.kt | 95 ++++++++++++++++--- 3 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/PdfSharer.kt diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfSharer.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfSharer.kt new file mode 100644 index 0000000..6ecdb5a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfSharer.kt @@ -0,0 +1,72 @@ +package de.medapp.wunddoku.pdf + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** + * Übergibt die fertige PDF-Datei selbst (statt in JPEG-Seiten umgewandelt, s. + * [PdfToImagesConverter]/[PdfImageSharer]) an den Android-"Senden an"-Dialog + * (`ACTION_SEND`, `application/pdf`) - Änderungswunsch: Nutzer soll beim Antippen des + * Teilen-Buttons wählen können, ob als JPG ODER als PDF geteilt wird. + * + * Bewusst getrennt von [PdfOpener] (öffnet dieselbe Datei stattdessen per `ACTION_VIEW` in + * einem PDF-Reader) - unterschiedliche Nutzerabsicht (ansehen vs. weitergeben), auch wenn + * beide dieselbe Datei über denselben FileProvider-Pfad freigeben. + */ +interface PdfSharer { + fun share(pdfFile: File): PdfShareResult +} + +sealed interface PdfShareResult { + data object Success : PdfShareResult + + sealed interface Failure : PdfShareResult { + data class UriResolutionFailed(val message: String) : Failure + data object NoAppAvailable : Failure + data class Unexpected(val message: String) : Failure + } +} + +fun PdfShareResult.Failure.toUserMessage(): String = when (this) { + is PdfShareResult.Failure.UriResolutionFailed -> + "Die PDF-Datei konnte nicht zum Versenden freigegeben werden." + PdfShareResult.Failure.NoAppAvailable -> + "Auf diesem Gerät ist keine App zum Versenden von PDFs installiert." + is PdfShareResult.Failure.Unexpected -> + "Die PDF-Datei konnte nicht versendet werden." +} + +class AndroidPdfSharer(private val context: Context) : PdfSharer { + + override fun share(pdfFile: File): PdfShareResult { + val authority = "${context.packageName}.fileprovider" + val uri = try { + FileProvider.getUriForFile(context, authority, pdfFile) + } catch (e: IllegalArgumentException) { + return PdfShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.") + } + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(intent, "PDF senden an") + + return try { + context.startActivity(chooser) + PdfShareResult.Success + } catch (e: ActivityNotFoundException) { + // Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in + // AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11). + PdfShareResult.Failure.NoAppAvailable + } catch (e: SecurityException) { + PdfShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.") + } catch (e: Exception) { + PdfShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der PDF-Datei.") + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index 007af8e..22637a5 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -46,6 +47,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -66,6 +68,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -78,8 +81,10 @@ import de.medapp.wunddoku.domain.model.Photo import de.medapp.wunddoku.domain.model.Signature import de.medapp.wunddoku.pdf.AndroidPdfImageSharer import de.medapp.wunddoku.pdf.AndroidPdfOpener +import de.medapp.wunddoku.pdf.AndroidPdfSharer import de.medapp.wunddoku.pdf.PdfImageShareResult import de.medapp.wunddoku.pdf.PdfOpenResult +import de.medapp.wunddoku.pdf.PdfShareResult import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog @@ -141,6 +146,7 @@ fun DocumentationEditScreen( val context = LocalContext.current val pdfOpener = remember(context) { AndroidPdfOpener(context) } val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) } + val pdfSharer = remember(context) { AndroidPdfSharer(context) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(uiState.pdfExportMessage) { val message = uiState.pdfExportMessage ?: return@LaunchedEffect @@ -163,7 +169,7 @@ fun DocumentationEditScreen( // Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken // können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s. - // DocumentationEditViewModel.exportAndShareAsImages), öffnet dieser Effekt den System- + // DocumentationEditViewModel.shareAsImages), öffnet dieser Effekt den System- // Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels. LaunchedEffect(uiState.imagesToShare) { val images = uiState.imagesToShare ?: return@LaunchedEffect @@ -173,10 +179,20 @@ fun DocumentationEditScreen( } viewModel.onImageShareHandled() } - LaunchedEffect(uiState.imageShareMessage) { - val message = uiState.imageShareMessage ?: return@LaunchedEffect + // Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s. + // DocumentationEditViewModel.shareAsPdf). + LaunchedEffect(uiState.pdfToShare) { + val pdfFile = uiState.pdfToShare ?: return@LaunchedEffect + when (val shareResult = pdfSharer.share(pdfFile)) { + PdfShareResult.Success -> Unit + is PdfShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage()) + } + viewModel.onPdfShareHandled() + } + LaunchedEffect(uiState.shareMessage) { + val message = uiState.shareMessage ?: return@LaunchedEffect snackbarHostState.showSnackbar(message) - viewModel.dismissImageShareMessage() + viewModel.dismissShareMessage() } Scaffold( @@ -206,14 +222,14 @@ fun DocumentationEditScreen( Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen") } } - // Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden. Exportiert - // intern frisch als PDF, wandelt seitenweise in JPEG um und öffnet - // anschließend automatisch den "Senden an"-Dialog (s. LaunchedEffect oben). - if (uiState.imageShareInProgress) { + // Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden, wahlweise + // auch als PDF selbst - Format wird per Dialog abgefragt (s. weiter unten), + // BEVOR irgendetwas exportiert wird. + if (uiState.shareInProgress) { CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) } else { - IconButton(onClick = viewModel::exportAndShareAsImages) { - Icon(Icons.Filled.Share, contentDescription = "Als Bilder senden") + IconButton(onClick = viewModel::requestShareFormatChoice) { + Icon(Icons.Filled.Share, contentDescription = "Teilen") } } // "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per @@ -346,6 +362,52 @@ fun DocumentationEditScreen( onDismiss = { viewedPhoto = null }, ) } + + // Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons. + if (uiState.showShareFormatDialog) { + ShareFormatDialog( + onDismiss = viewModel::dismissShareFormatDialog, + onChooseImages = viewModel::shareAsImages, + onChoosePdf = viewModel::shareAsPdf, + ) + } +} + +/** + * Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als + * PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird + * überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) - + * kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht. + */ +@Composable +private fun ShareFormatDialog( + onDismiss: () -> Unit, + onChooseImages: () -> Unit, + onChoosePdf: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) { + Text("Teilen als", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text( + "Als Bilder (JPG) oder als PDF teilen?", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = onChooseImages) { Text("JPG") } + Spacer(Modifier.width(8.dp)) + Button(onClick = onChoosePdf) { Text("PDF") } + } + } + } + } } /** diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index 8afda87..91bf8ca 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -65,12 +65,17 @@ data class DocumentationEditUiState( * referenziert werden kann, während ein neuer Export läuft. */ val lastExportedPdfFile: File? = null, - /** true, während PDF-Export + JPEG-Konvertierung für den Bilderversand laufen (ein - * gemeinsamer Ladezustand für beide Schritte, s. [DocumentationEditViewModel.exportAndShareAsImages]). */ - val imageShareInProgress: Boolean = false, - /** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - erfolgreich - * erzeugte Bilder lösen dagegen KEINE Meldung aus, sondern direkt [imagesToShare]. */ - val imageShareMessage: String? = null, + /** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer + * Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages], + * PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert + * wird. */ + val shareInProgress: Boolean = false, + /** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - ein erfolgreicher + * Export löst dagegen KEINE Meldung aus, sondern direkt [imagesToShare] bzw. [pdfToShare]. */ + val shareMessage: String? = null, + /** true, solange die Auswahl "Als JPG oder PDF teilen?" angezeigt wird (Änderungswunsch: + * Format-Auswahl beim Antippen des Teilen-Buttons, bevor irgendetwas exportiert wird). */ + val showShareFormatDialog: Boolean = false, /** * Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder * über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect @@ -79,6 +84,9 @@ data class DocumentationEditUiState( * Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels. */ val imagesToShare: List? = null, + /** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück + * [DocumentationEditViewModel.onPdfShareHandled]. */ + val pdfToShare: File? = null, ) { val blockCount: Int get() = documentation?.blocks?.size ?: 0 @@ -373,6 +381,31 @@ class DocumentationEditViewModel( _uiState.update { it.copy(pdfExportMessage = null) } } + /** + * Öffnet die Auswahl "Als JPG oder PDF teilen?" (Änderungswunsch: Format-Auswahl beim + * Antippen des Teilen-Buttons, BEVOR irgendetwas exportiert wird - vorher wurde beim + * Antippen sofort und ausschließlich als JPG geteilt). + */ + fun requestShareFormatChoice() { + _uiState.update { it.copy(showShareFormatDialog = true) } + } + + fun dismissShareFormatDialog() { + _uiState.update { it.copy(showShareFormatDialog = false) } + } + + /** Nutzer hat sich für "Als JPG teilen" entschieden. */ + fun shareAsImages() { + _uiState.update { it.copy(showShareFormatDialog = false) } + exportAndShareAsImages() + } + + /** Nutzer hat sich für "Als PDF teilen" entschieden. */ + fun shareAsPdf() { + _uiState.update { it.copy(showShareFormatDialog = false) } + exportAndSharePdf() + } + /** * Exportiert die Dokumentation frisch als PDF und wandelt das Ergebnis anschließend * seitenweise in JPEG-Bilder um (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden @@ -384,10 +417,10 @@ class DocumentationEditViewModel( * der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist * UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]). */ - fun exportAndShareAsImages() { + private fun exportAndShareAsImages() { viewModelScope.launch { _uiState.update { - it.copy(imageShareInProgress = true, imageShareMessage = null, imagesToShare = null) + it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null) } val exportResult = repository.exportPdf(documentationId) @@ -396,8 +429,8 @@ class DocumentationEditViewModel( is PdfExportResult.Failure -> { _uiState.update { it.copy( - imageShareInProgress = false, - imageShareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}", + shareInProgress = false, + shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}", ) } return@launch @@ -406,13 +439,40 @@ class DocumentationEditViewModel( when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) { is PdfToImagesResult.Success -> _uiState.update { - it.copy(imageShareInProgress = false, imagesToShare = imagesResult.imageFiles) + it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles) } is PdfToImagesResult.Failure -> _uiState.update { it.copy( - imageShareInProgress = false, - imageShareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}", + shareInProgress = false, + shareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}", + ) + } + } + } + } + + /** + * Exportiert die Dokumentation frisch als PDF und stellt sie zum Teilen bereit + * (Änderungswunsch: Format-Auswahl "PDF" statt "JPG") - ohne die JPEG-Konvertierung, die + * geteilte Datei ist die PDF selbst. Das eigentliche Teilen übernimmt der Screen anhand von + * [DocumentationEditUiState.pdfToShare] (s. [de.medapp.wunddoku.pdf.PdfSharer]). + */ + private fun exportAndSharePdf() { + viewModelScope.launch { + _uiState.update { + it.copy(shareInProgress = true, shareMessage = null, pdfToShare = null) + } + + when (val exportResult = repository.exportPdf(documentationId)) { + is PdfExportResult.Success -> _uiState.update { + it.copy(shareInProgress = false, pdfToShare = exportResult.file) + } + + is PdfExportResult.Failure -> _uiState.update { + it.copy( + shareInProgress = false, + shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}", ) } } @@ -426,8 +486,13 @@ class DocumentationEditViewModel( _uiState.update { it.copy(imagesToShare = null) } } - fun dismissImageShareMessage() { - _uiState.update { it.copy(imageShareMessage = null) } + /** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */ + fun onPdfShareHandled() { + _uiState.update { it.copy(pdfToShare = null) } + } + + fun dismissShareMessage() { + _uiState.update { it.copy(shareMessage = null) } } private fun PdfExportError.toUserMessage(): String = when (this) {