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.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult 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.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.File
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -60,6 +63,7 @@ class DocumentationRepository(
private val signatureStorage: SignatureStorage, private val signatureStorage: SignatureStorage,
private val woundCarerRepository: WoundCarerRepository, private val woundCarerRepository: WoundCarerRepository,
private val pdfExporter: DocumentationPdfExporter, private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase, private val db: AppDatabase,
) { ) {
@@ -284,6 +288,16 @@ class DocumentationRepository(
return pdfExporter.export(model) 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 * Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von * 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.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
@@ -38,6 +40,7 @@ class AppContainer(context: Context) {
private val pdfExporter: DocumentationPdfExporter by lazy { private val pdfExporter: DocumentationPdfExporter by lazy {
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage) AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
} }
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
val patientRepository: PatientRepository by lazy { val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao()) PatientRepository(patientDao = database.patientDao())
@@ -62,6 +65,7 @@ class AppContainer(context: Context) {
signatureStorage = signatureStorage, signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository, woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter, pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = database, 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.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
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@@ -45,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
@@ -65,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
@@ -75,8 +79,12 @@ import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.Photo 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.AndroidPdfOpener 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.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
@@ -137,6 +145,8 @@ fun DocumentationEditScreen(
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe. // Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
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 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
@@ -157,6 +167,34 @@ fun DocumentationEditScreen(
viewModel.dismissPdfExportMessage() 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( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
@@ -175,9 +213,8 @@ fun DocumentationEditScreen(
} }
}, },
actions = { actions = {
// PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt // PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
// ausschließlich die Datei im internen Speicher (PDF-001/002), als // erzeugt die Datei im internen Speicher (PDF-001/002).
// minimaler Trigger zum manuellen Testen der Export-Funktionalität.
if (uiState.pdfExportInProgress) { if (uiState.pdfExportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else { } else {
@@ -185,6 +222,16 @@ fun DocumentationEditScreen(
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen") 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 // "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
// Autosave gesichert, kein expliziter Speichern-Button nötig). // Autosave gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") } Button(onClick = onFinished) { Text("Fertig") }
@@ -315,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") }
}
}
}
}
} }
/** /**
@@ -15,6 +15,8 @@ import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult 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 de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -63,6 +65,28 @@ 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 + 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 val blockCount: Int get() = documentation?.blocks?.size ?: 0
@@ -357,6 +381,120 @@ 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
* 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) { private fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden." PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden." 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"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" /> <files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" /> <files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" /> <files-path name="signatures" path="signatures/" />
</paths> </paths>