MEDIA-005 #9

Merged
niklas.ortmann merged 1 commits from dev-MEDIA-005 into dev 2026-09-27 17:28:09 +02:00
3 changed files with 224 additions and 25 deletions
@@ -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.")
}
}
}
@@ -16,6 +16,7 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -46,6 +47,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner 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.domain.model.Signature
import de.medapp.wunddoku.pdf.AndroidPdfImageSharer import de.medapp.wunddoku.pdf.AndroidPdfImageSharer
import de.medapp.wunddoku.pdf.AndroidPdfOpener import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.AndroidPdfSharer
import de.medapp.wunddoku.pdf.PdfImageShareResult import de.medapp.wunddoku.pdf.PdfImageShareResult
import de.medapp.wunddoku.pdf.PdfOpenResult import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.PdfShareResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
@@ -141,6 +146,7 @@ fun DocumentationEditScreen(
val context = LocalContext.current val context = LocalContext.current
val pdfOpener = remember(context) { AndroidPdfOpener(context) } val pdfOpener = remember(context) { AndroidPdfOpener(context) }
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) } val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.pdfExportMessage) { LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect val message = uiState.pdfExportMessage ?: return@LaunchedEffect
@@ -163,7 +169,7 @@ fun DocumentationEditScreen(
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken // Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s. // 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. // Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
LaunchedEffect(uiState.imagesToShare) { LaunchedEffect(uiState.imagesToShare) {
val images = uiState.imagesToShare ?: return@LaunchedEffect val images = uiState.imagesToShare ?: return@LaunchedEffect
@@ -173,10 +179,20 @@ fun DocumentationEditScreen(
} }
viewModel.onImageShareHandled() viewModel.onImageShareHandled()
} }
LaunchedEffect(uiState.imageShareMessage) { // Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s.
val message = uiState.imageShareMessage ?: return@LaunchedEffect // 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) snackbarHostState.showSnackbar(message)
viewModel.dismissImageShareMessage() viewModel.dismissShareMessage()
} }
Scaffold( Scaffold(
@@ -206,14 +222,14 @@ fun DocumentationEditScreen(
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen") Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
} }
} }
// Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden. Exportiert // Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden, wahlweise
// intern frisch als PDF, wandelt seitenweise in JPEG um und öffnet // auch als PDF selbst - Format wird per Dialog abgefragt (s. weiter unten),
// anschließend automatisch den "Senden an"-Dialog (s. LaunchedEffect oben). // BEVOR irgendetwas exportiert wird.
if (uiState.imageShareInProgress) { if (uiState.shareInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else { } else {
IconButton(onClick = viewModel::exportAndShareAsImages) { IconButton(onClick = viewModel::requestShareFormatChoice) {
Icon(Icons.Filled.Share, contentDescription = "Als Bilder senden") Icon(Icons.Filled.Share, contentDescription = "Teilen")
} }
} }
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per // "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
@@ -346,6 +362,52 @@ fun DocumentationEditScreen(
onDismiss = { viewedPhoto = null }, 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") }
}
}
}
}
} }
/** /**
@@ -65,12 +65,17 @@ data class DocumentationEditUiState(
* referenziert werden kann, während ein neuer Export läuft. * referenziert werden kann, während ein neuer Export läuft.
*/ */
val lastExportedPdfFile: File? = null, val lastExportedPdfFile: File? = null,
/** true, während PDF-Export + JPEG-Konvertierung für den Bilderversand laufen (ein /** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer
* gemeinsamer Ladezustand für beide Schritte, s. [DocumentationEditViewModel.exportAndShareAsImages]). */ * Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages],
val imageShareInProgress: Boolean = false, * PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert
/** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - erfolgreich * wird. */
* erzeugte Bilder lösen dagegen KEINE Meldung aus, sondern direkt [imagesToShare]. */ val shareInProgress: Boolean = false,
val imageShareMessage: String? = null, /** 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 * Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder
* über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect * ü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. * Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
*/ */
val imagesToShare: List<File>? = null, val imagesToShare: List<File>? = 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 val blockCount: Int get() = documentation?.blocks?.size ?: 0
@@ -373,6 +381,31 @@ class DocumentationEditViewModel(
_uiState.update { it.copy(pdfExportMessage = null) } _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 * 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 * 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 * der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]). * UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
*/ */
fun exportAndShareAsImages() { private fun exportAndShareAsImages() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { _uiState.update {
it.copy(imageShareInProgress = true, imageShareMessage = null, imagesToShare = null) it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
} }
val exportResult = repository.exportPdf(documentationId) val exportResult = repository.exportPdf(documentationId)
@@ -396,8 +429,8 @@ class DocumentationEditViewModel(
is PdfExportResult.Failure -> { is PdfExportResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
imageShareInProgress = false, shareInProgress = false,
imageShareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}", shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
) )
} }
return@launch return@launch
@@ -406,13 +439,40 @@ class DocumentationEditViewModel(
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) { when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
is PdfToImagesResult.Success -> _uiState.update { is PdfToImagesResult.Success -> _uiState.update {
it.copy(imageShareInProgress = false, imagesToShare = imagesResult.imageFiles) it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
} }
is PdfToImagesResult.Failure -> _uiState.update { is PdfToImagesResult.Failure -> _uiState.update {
it.copy( it.copy(
imageShareInProgress = false, shareInProgress = false,
imageShareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}", 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) } _uiState.update { it.copy(imagesToShare = null) }
} }
fun dismissImageShareMessage() { /** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */
_uiState.update { it.copy(imageShareMessage = null) } fun onPdfShareHandled() {
_uiState.update { it.copy(pdfToShare = null) }
}
fun dismissShareMessage() {
_uiState.update { it.copy(shareMessage = null) }
} }
private fun PdfExportError.toUserMessage(): String = when (this) { private fun PdfExportError.toUserMessage(): String = when (this) {