Compare commits

..

3 Commits

Author SHA1 Message Date
niklas.ortmann 63bc93ebd2 MEDIA-005
Export fertig
2026-09-27 17:27:14 +02:00
niklas.ortmann 08091a15a2 MEDIA-005
JPG-Export
2026-09-24 13:28:22 +02:00
niklas.ortmann 4a62d195ac Merge pull request 'Box-Inhalte' (#7) from dev-Inhalte into dev
Reviewed-on: #7
2026-09-24 13:13:23 +02:00
8 changed files with 541 additions and 3 deletions
@@ -29,10 +29,13 @@ import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.io.File
import java.time.Instant
import java.util.UUID
@@ -60,6 +63,7 @@ class DocumentationRepository(
private val signatureStorage: SignatureStorage,
private val woundCarerRepository: WoundCarerRepository,
private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase,
) {
@@ -284,6 +288,16 @@ class DocumentationRepository(
return pdfExporter.export(model)
}
/**
* Rendert eine bereits erzeugte PDF-Datei (s. [exportPdf]) seitenweise in JPEG-Dateien
* (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können"). Nimmt bewusst die
* fertige [pdfFile] entgegen, statt die Dokumentation erneut zu laden/zu mappen - der
* Aufrufer hat sie direkt aus einem vorangegangenen [exportPdf]-Aufruf zur Verfügung, ein
* erneutes Laden wäre unnötige Doppelarbeit.
*/
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
pdfToImagesConverter.convert(documentationId, pdfFile)
/**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
@@ -8,7 +8,9 @@ import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage
@@ -38,6 +40,7 @@ class AppContainer(context: Context) {
private val pdfExporter: DocumentationPdfExporter by lazy {
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
}
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao())
@@ -62,6 +65,7 @@ class AppContainer(context: Context) {
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = database,
)
}
@@ -0,0 +1,99 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt alle als JPEG gerenderten PDF-Seiten (s. [PdfToImagesConverter]) GEMEINSAM an den
* Android-"Senden an"-Dialog (`ACTION_SEND_MULTIPLE`) - Änderungswunsch: "alle generierten
* Bilder über 'Senden an' direkt verschicken können", nicht Seite für Seite einzeln.
*
* Bewusst getrennt von [PdfToImagesConverter] (reine Datei-Logik): das Starten einer Activity
* ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe - exakt dieselbe Trennung wie bei
* [DocumentationPdfExporter] (Erzeugung) vs. [PdfOpener] (Anzeige).
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen Versand-Mechanismus; welche konkrete App
* (Mail, Messenger, Cloud-Speicher, ...) die Bilder entgegennimmt, entscheidet ausschließlich
* Android/der Nutzer über den System-Dialog.
*/
interface PdfImageSharer {
fun share(imageFiles: List<File>): PdfImageShareResult
}
sealed interface PdfImageShareResult {
data object Success : PdfImageShareResult
sealed interface Failure : PdfImageShareResult {
/** Es wurden keine Bilddateien übergeben - nichts zum Teilen vorhanden. */
data object NoImages : Failure
/** FileProvider konnte für mindestens eine Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Keine installierte App kann Bilder (image/jpeg) entgegennehmen. */
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfImageShareResult.Failure.toUserMessage(): String = when (this) {
PdfImageShareResult.Failure.NoImages ->
"Es sind keine Bilder zum Versenden vorhanden."
is PdfImageShareResult.Failure.UriResolutionFailed ->
"Die Bilder konnten nicht zum Versenden freigegeben werden."
PdfImageShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von Bildern installiert."
is PdfImageShareResult.Failure.Unexpected ->
"Die Bilder konnten nicht versendet werden."
}
class AndroidPdfImageSharer(private val context: Context) : PdfImageSharer {
override fun share(imageFiles: List<File>): PdfImageShareResult {
if (imageFiles.isEmpty()) return PdfImageShareResult.Failure.NoImages
val authority = "${context.packageName}.fileprovider"
val uris = try {
imageFiles.map { file -> FileProvider.getUriForFile(context, authority, file) }
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn eine Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfImageShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "image/jpeg"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// Zusätzlich zum Flag oben wird die Leseberechtigung explizit über ClipData an JEDE
// einzelne Uri gehängt - bei ACTION_SEND_MULTIPLE mit mehreren content://-Uris ist
// das der zuverlässige Weg, damit wirklich ALLE empfangenden Apps Zugriff auf ALLE
// Bilder bekommen (nicht nur auf die im Intent-Datenfeld primär referenzierte).
clipData = ClipData.newUri(context.contentResolver, "Wunddokumentation", uris.first()).apply {
for (uri in uris.drop(1)) {
addItem(ClipData.Item(uri))
}
}
}
val chooser = Intent.createChooser(intent, "Bilder senden an")
return try {
context.startActivity(chooser)
PdfImageShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfImageShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die Bilddateien wurde verweigert.")
} catch (e: Exception) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der Bilder.")
}
}
}
@@ -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.")
}
}
}
@@ -0,0 +1,117 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
/**
* Rendert eine bereits fertig erzeugte PDF-Datei (s. [DocumentationPdfExporter]) seitenweise in
* einzelne JPEG-Dateien - Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können".
*
* Bewusst als eigener Schritt NACH dem PDF-Export statt als Alternative dazu: Es gibt weiterhin
* genau eine Quelle der Wahrheit für das Seitenlayout (der bestehende [PdfPageCursor]-basierte
* Export) - dieser Konverter rastert lediglich das bereits fertige Ergebnis, statt eine zweite,
* parallele Layout-Logik zu pflegen.
*
* Verwendet [android.graphics.pdf.PdfRenderer] (Android-Bordmittel seit API 21, unproblematisch
* bei minSdk 26) - keine zusätzliche Bibliothek nötig. Eine mehrseitige Wunddokumentation (Kopf/
* Blöcke, Signaturen, ggf. 30+ Fotoseiten) ergibt dabei bewusst EIN JPEG PRO SEITE, kein
* einzelnes Sammelbild - beim späteren Teilen (s. [PdfImageSharer]) werden alle Seiten
* gemeinsam als Bilder-Mehrfachauswahl übergeben.
*/
interface PdfToImagesConverter {
suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult
}
sealed interface PdfToImagesResult {
data class Success(val imageFiles: List<File>) : PdfToImagesResult
sealed interface Failure : PdfToImagesResult {
/** Die übergebene PDF-Datei existiert nicht (mehr). */
data object FileNotFound : Failure
/** PdfRenderer meldet 0 Seiten - sollte bei einem selbst erzeugten PDF nie vorkommen. */
data object EmptyDocument : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
PdfToImagesResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte zuerst die Dokumentation erneut exportieren."
PdfToImagesResult.Failure.EmptyDocument ->
"Die PDF-Datei enthält keine Seiten."
is PdfToImagesResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
}
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult =
withContext(Dispatchers.IO) {
if (!pdfFile.exists()) return@withContext PdfToImagesResult.Failure.FileNotFound
val outputDir = File(File(context.filesDir, "pdf_images"), documentationId)
// Bilder eines vorherigen Exports zuerst entfernen, damit bei einer inzwischen
// kürzeren PDF (z. B. Fotos gelöscht) keine veralteten, zu vielen Seiten-Dateien
// liegen bleiben.
outputDir.deleteRecursively()
outputDir.mkdirs()
try {
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { renderer ->
if (renderer.pageCount == 0) {
return@withContext PdfToImagesResult.Failure.EmptyDocument
}
val imageFiles = mutableListOf<File>()
for (pageIndex in 0 until renderer.pageCount) {
renderer.openPage(pageIndex).use { page ->
val scale = RENDER_DPI / POINTS_PER_INCH
val width = (page.width * scale).roundToInt().coerceAtLeast(1)
val height = (page.height * scale).roundToInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WICHTIG: PdfRenderer zeichnet nur die tatsächlich im PDF
// vorhandenen Inhalte - unbedeckte Flächen bleiben transparent
// (Alpha 0). JPEG kennt keine Transparenz; ohne dieses
// Vorab-Weißfüllen würde jede unbedeckte Fläche beim
// Komprimieren als SCHWARZ statt als weißer Seitenhintergrund
// enden (bekannte PdfRenderer-Falle, s. offizielle Android-
// Beispiele).
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val imageFile = File(outputDir, "seite_%02d.jpg".format(pageIndex + 1))
FileOutputStream(imageFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
}
bitmap.recycle()
imageFiles.add(imageFile)
}
}
PdfToImagesResult.Success(imageFiles)
}
}
} catch (e: Exception) {
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
}
}
companion object {
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
* ein für Ausdruck/Weitergabe ausreichend scharfes, aber nicht unnötig großes Bild. */
private const val RENDER_DPI = 200f
private const val POINTS_PER_INCH = 72f
private const val JPEG_QUALITY = 90
}
}
@@ -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
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
@@ -45,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
@@ -65,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
@@ -75,8 +79,12 @@ import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
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
@@ -137,6 +145,8 @@ fun DocumentationEditScreen(
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
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
@@ -157,6 +167,34 @@ fun DocumentationEditScreen(
viewModel.dismissPdfExportMessage()
}
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s.
// 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
when (val shareResult = pdfImageSharer.share(images)) {
PdfImageShareResult.Success -> Unit
is PdfImageShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onImageShareHandled()
}
// 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.dismissShareMessage()
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
@@ -175,9 +213,8 @@ fun DocumentationEditScreen(
}
},
actions = {
// PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt
// ausschließlich die Datei im internen Speicher (PDF-001/002), als
// minimaler Trigger zum manuellen Testen der Export-Funktionalität.
// PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
// erzeugt die Datei im internen Speicher (PDF-001/002).
if (uiState.pdfExportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
@@ -185,6 +222,16 @@ fun DocumentationEditScreen(
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
}
}
// Ä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::requestShareFormatChoice) {
Icon(Icons.Filled.Share, contentDescription = "Teilen")
}
}
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
// Autosave gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") }
@@ -315,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") }
}
}
}
}
}
/**
@@ -15,6 +15,8 @@ import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -63,6 +65,28 @@ data class DocumentationEditUiState(
* referenziert werden kann, während ein neuer Export läuft.
*/
val lastExportedPdfFile: File? = 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
* beobachtet, der daraufhin den System-"Senden an"-Dialog öffnet und anschließend
* [DocumentationEditViewModel.onImageShareHandled] aufruft - das Teilen selbst (Intent/
* Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
*/
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
@@ -357,6 +381,120 @@ 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
* können"). Bewusst ein eigener, vollständiger Export-Lauf statt Wiederverwendung von
* [uiState.lastExportedPdfFile]: so spiegeln die Bilder IMMER den aktuellen Stand wider,
* unabhängig davon, ob/wann zuvor schon einmal "PDF erzeugen" angetippt wurde.
*
* Das eigentliche Teilen (Android-"Senden an"-Dialog) übernimmt NICHT diese Methode, sondern
* der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
*/
private fun exportAndShareAsImages() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
}
val exportResult = repository.exportPdf(documentationId)
val pdfFile = when (exportResult) {
is PdfExportResult.Success -> exportResult.file
is PdfExportResult.Failure -> {
_uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
return@launch
}
}
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
is PdfToImagesResult.Success -> _uiState.update {
it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
}
is PdfToImagesResult.Failure -> _uiState.update {
it.copy(
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()}",
)
}
}
}
}
/** Wird vom Screen aufgerufen, nachdem der "Senden an"-Dialog für [DocumentationEditUiState.imagesToShare]
* geöffnet wurde (erfolgreich oder nicht) - verhindert ein erneutes Öffnen bei der nächsten
* Recomposition, solange keine neuen Bilder erzeugt wurden. */
fun onImageShareHandled() {
_uiState.update { it.copy(imagesToShare = 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) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
+1
View File
@@ -7,6 +7,7 @@
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" />
</paths>