Beta 1.0 Release #11
@@ -25,6 +25,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.media.ImageDownsampler
|
import de.medapp.wunddoku.media.ImageDownsampler
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
|
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.signature.SignatureFileStorage
|
import de.medapp.wunddoku.signature.SignatureFileStorage
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -39,6 +43,10 @@ import java.util.UUID
|
|||||||
*
|
*
|
||||||
* [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein
|
* [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein
|
||||||
* frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation).
|
* frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation).
|
||||||
|
* [pdfExporter] wird für [exportPdf] benötigt - der Exporter selbst kennt weder Room noch die
|
||||||
|
* documentationId, nur das fertig gemappte [de.medapp.wunddoku.pdf.PdfExportModel]; das Laden
|
||||||
|
* der Dokumentation und die Fehlerübersetzung ("Dokumentation existiert nicht") übernimmt
|
||||||
|
* bewusst das Repository, nicht der Exporter.
|
||||||
*/
|
*/
|
||||||
class DocumentationRepository(
|
class DocumentationRepository(
|
||||||
private val documentationDao: DocumentationDao,
|
private val documentationDao: DocumentationDao,
|
||||||
@@ -49,6 +57,7 @@ class DocumentationRepository(
|
|||||||
private val patientDao: PatientDao,
|
private val patientDao: PatientDao,
|
||||||
private val photoStorage: PhotoStorage,
|
private val photoStorage: PhotoStorage,
|
||||||
private val signatureFileStorage: SignatureFileStorage,
|
private val signatureFileStorage: SignatureFileStorage,
|
||||||
|
private val pdfExporter: DocumentationPdfExporter,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -113,8 +122,10 @@ class DocumentationRepository(
|
|||||||
* - status: IN_PROGRESS
|
* - status: IN_PROGRESS
|
||||||
* - continuedFromDocumentationId: ID der Quelle
|
* - continuedFromDocumentationId: ID der Quelle
|
||||||
* - Unterschriften: werden NICHT kopiert
|
* - Unterschriften: werden NICHT kopiert
|
||||||
* - Fotos: über [copyPhotos] steuerbar, Standard `false` - fachlich noch nicht final
|
* - Fotos: werden standardmäßig mit übernommen (physisch eigenständige Kopien pro
|
||||||
* entschieden
|
* Dokumentation, s. [PhotoStorage.duplicate]) - fachliche Entscheidung, ursprünglich als
|
||||||
|
* offen markiert, inzwischen bewusst so festgelegt. Über [copyPhotos] weiterhin
|
||||||
|
* abschaltbar, falls dafür später doch ein Anwendungsfall entsteht.
|
||||||
*/
|
*/
|
||||||
suspend fun continueDocumentation(
|
suspend fun continueDocumentation(
|
||||||
sourceDocumentationId: String,
|
sourceDocumentationId: String,
|
||||||
@@ -255,6 +266,21 @@ class DocumentationRepository(
|
|||||||
runCatching { photoStorage.delete(photo.documentationId, photo.fileName) }
|
runCatching { photoStorage.delete(photo.documentationId, photo.fileName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt aus einer Wunddokumentation ein PDF im internen App-Speicher (PDF-001/002). Lädt
|
||||||
|
* die vollständige Dokumentation, übersetzt "nicht gefunden" in einen fachlichen Fehler
|
||||||
|
* statt einer NullPointerException, mappt anschließend auf das schlanke [de.medapp.wunddoku.pdf.PdfExportModel]
|
||||||
|
* (dabei werden über [DocumentationToPdfExportModelMapper] ausschließlich bearbeitete
|
||||||
|
* Blöcke gemäß der bestehenden BlockEditedPolicy sowie die historischen Snapshot-Werte
|
||||||
|
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
|
||||||
|
*/
|
||||||
|
suspend fun exportPdf(documentationId: String): PdfExportResult {
|
||||||
|
val documentation = getFullDocumentation(documentationId)
|
||||||
|
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
|
||||||
|
val model = DocumentationToPdfExportModelMapper.map(documentation)
|
||||||
|
return pdfExporter.export(model)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import de.medapp.wunddoku.data.repository.DocumentationRepository
|
|||||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||||
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.DocumentationPdfExporter
|
||||||
import de.medapp.wunddoku.signature.InternalSignatureFileStorage
|
import de.medapp.wunddoku.signature.InternalSignatureFileStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureFileStorage
|
import de.medapp.wunddoku.signature.SignatureFileStorage
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
|
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
|
||||||
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) }
|
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) }
|
||||||
|
private val pdfExporter: DocumentationPdfExporter by lazy { AndroidPdfDocumentExporter(context) }
|
||||||
|
|
||||||
val patientRepository: PatientRepository by lazy {
|
val patientRepository: PatientRepository by lazy {
|
||||||
PatientRepository(patientDao = database.patientDao())
|
PatientRepository(patientDao = database.patientDao())
|
||||||
@@ -42,6 +45,7 @@ class AppContainer(context: Context) {
|
|||||||
patientDao = database.patientDao(),
|
patientDao = database.patientDao(),
|
||||||
photoStorage = photoStorage,
|
photoStorage = photoStorage,
|
||||||
signatureFileStorage = signatureFileStorage,
|
signatureFileStorage = signatureFileStorage,
|
||||||
|
pdfExporter = pdfExporter,
|
||||||
db = database,
|
db = database,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.pdf.PdfDocument
|
||||||
|
import android.text.TextPaint
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konkrete [DocumentationPdfExporter]-Implementierung auf Basis von `android.graphics.pdf.PdfDocument`.
|
||||||
|
*
|
||||||
|
* Checkboxen werden bewusst NICHT über Unicode-Zeichen (☒/☐) oder sonstige Font-Glyphen
|
||||||
|
* dargestellt, sondern ausschließlich über direkte Canvas-Vektorprimitive (`drawRect`/
|
||||||
|
* `drawLine`) gezeichnet - dadurch ist das PDF geräte- und schriftartunabhängig exakt
|
||||||
|
* reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert
|
||||||
|
* ist bzw. welche Unicode-Symbole sie abdeckt.
|
||||||
|
*
|
||||||
|
* Speicherort: `filesDir/pdf/<documentationId>/<Dateiname>.pdf` - ausschließlich intern, keine
|
||||||
|
* MediaStore-Nutzung, kein öffentlicher Speicher (PDF-005/006 mit FileProvider/Sharesheet folgen
|
||||||
|
* später).
|
||||||
|
*/
|
||||||
|
class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPdfExporter {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm.
|
||||||
|
private const val PAGE_WIDTH = 595
|
||||||
|
private const val PAGE_HEIGHT = 842
|
||||||
|
private const val MARGIN = 40f
|
||||||
|
|
||||||
|
private const val TITLE_TEXT_SIZE = 18f
|
||||||
|
private const val SUBTITLE_TEXT_SIZE = 12f
|
||||||
|
private const val HEADER_INFO_TEXT_SIZE = 11f
|
||||||
|
private const val BLOCK_TITLE_TEXT_SIZE = 14f
|
||||||
|
private const val ITEM_TEXT_SIZE = 11f
|
||||||
|
private const val FREE_TEXT_TEXT_SIZE = 10.5f
|
||||||
|
private const val FREE_TEXT_INDENT = 20f
|
||||||
|
|
||||||
|
// Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text.
|
||||||
|
private const val CHECKBOX_SIZE = 10f
|
||||||
|
private const val CHECKBOX_GAP = 6f
|
||||||
|
private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP
|
||||||
|
|
||||||
|
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun export(model: PdfExportModel): PdfExportResult = withContext(Dispatchers.IO) {
|
||||||
|
val targetDir = File(File(context.filesDir, "pdf"), model.documentationId)
|
||||||
|
if (!targetDir.exists() && !targetDir.mkdirs()) {
|
||||||
|
return@withContext PdfExportResult.Failure(PdfExportError.DirectoryCreationFailed(targetDir.absolutePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetFile = File(targetDir, model.fileName)
|
||||||
|
// Erst in eine temporäre Datei schreiben und nur bei vollständigem Erfolg auf den
|
||||||
|
// endgültigen Dateinamen umbenennen - so kann am Zielpfad selbst nie eine halbfertige
|
||||||
|
// Datei liegen bleiben, auch nicht bei einem Fehler mitten im Schreibvorgang.
|
||||||
|
val tempFile = File(targetDir, "${model.fileName}.tmp")
|
||||||
|
|
||||||
|
val pdfDocument = PdfDocument()
|
||||||
|
try {
|
||||||
|
val paints = ExportPaints()
|
||||||
|
val cursor = PdfPageCursor(
|
||||||
|
pdfDocument = pdfDocument,
|
||||||
|
pageWidth = PAGE_WIDTH,
|
||||||
|
pageHeight = PAGE_HEIGHT,
|
||||||
|
marginLeft = MARGIN,
|
||||||
|
marginRight = MARGIN,
|
||||||
|
marginTop = MARGIN,
|
||||||
|
marginBottom = MARGIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
drawHeader(cursor, model, paints)
|
||||||
|
for (block in model.blocks) {
|
||||||
|
drawBlock(cursor, block, paints)
|
||||||
|
}
|
||||||
|
cursor.finish()
|
||||||
|
|
||||||
|
FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) }
|
||||||
|
|
||||||
|
if (targetFile.exists()) targetFile.delete()
|
||||||
|
if (!tempFile.renameTo(targetFile)) {
|
||||||
|
tempFile.delete()
|
||||||
|
return@withContext PdfExportResult.Failure(
|
||||||
|
PdfExportError.WriteFailed("PDF konnte nicht am endgültigen Speicherort abgelegt werden.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfExportResult.Success(targetFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tempFile.delete()
|
||||||
|
PdfExportResult.Failure(
|
||||||
|
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
pdfDocument.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bündelt alle für den Export verwendeten Paints. */
|
||||||
|
private class ExportPaints {
|
||||||
|
val title = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = TITLE_TEXT_SIZE
|
||||||
|
isFakeBoldText = true
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val subtitle = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = SUBTITLE_TEXT_SIZE
|
||||||
|
color = Color.DKGRAY
|
||||||
|
}
|
||||||
|
val headerInfo = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = HEADER_INFO_TEXT_SIZE
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val blockTitle = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = BLOCK_TITLE_TEXT_SIZE
|
||||||
|
isFakeBoldText = true
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val item = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = ITEM_TEXT_SIZE
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val freeText = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = FREE_TEXT_TEXT_SIZE
|
||||||
|
color = Color.DKGRAY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit.
|
||||||
|
val checkboxOutline = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = 1.2f
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val checkboxMark = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = 1.5f
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawHeader(cursor: PdfPageCursor, model: PdfExportModel, paints: ExportPaints) {
|
||||||
|
cursor.drawText("VulneraDoc", paints.title)
|
||||||
|
cursor.drawText("Wunddokumentation", paints.subtitle, spacingAfter = 8f)
|
||||||
|
|
||||||
|
val infoLines = listOf(
|
||||||
|
"Patient: ${model.patientFullName}",
|
||||||
|
"Geburtsdatum: ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}",
|
||||||
|
"Adresse: ${model.patientDisplayAddress}",
|
||||||
|
"Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}",
|
||||||
|
)
|
||||||
|
for ((index, line) in infoLines.withIndex()) {
|
||||||
|
val spacing = if (index == infoLines.lastIndex) 12f else 2f
|
||||||
|
cursor.drawText(line, paints.headerInfo, spacingAfter = spacing)
|
||||||
|
}
|
||||||
|
cursor.drawDivider(spacingAfter = 12f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
|
||||||
|
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f)
|
||||||
|
|
||||||
|
for (item in block.items) {
|
||||||
|
drawItem(cursor, item, paints)
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
|
||||||
|
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
|
||||||
|
val labelText = if (item.isFreeText) "${item.label}:" else item.label
|
||||||
|
val checked = item.checked
|
||||||
|
|
||||||
|
cursor.drawText(
|
||||||
|
text = labelText,
|
||||||
|
paint = paints.item,
|
||||||
|
indent = CHECKBOX_INDENT,
|
||||||
|
spacingAfter = 3f,
|
||||||
|
leadingMark = { canvas, left, top, bottom ->
|
||||||
|
drawCheckboxGlyph(canvas, left, top, bottom, checked, paints)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Freitext nur zeichnen, wenn die Option angehakt UND tatsächlich Text vorhanden ist -
|
||||||
|
// ein angehakter, aber leerer Freitext erzeugt keinen leeren Bereich im PDF.
|
||||||
|
if (item.isFreeText && checked && item.freeTextValue.isNotBlank()) {
|
||||||
|
cursor.drawText(
|
||||||
|
text = item.freeTextValue,
|
||||||
|
paint = paints.freeText,
|
||||||
|
indent = FREE_TEXT_INDENT,
|
||||||
|
spacingAfter = 6f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet ein Checkbox-Symbol rein per Canvas-Vektorprimitiven: ein Quadrat-Umriss, bei
|
||||||
|
* `checked = true` zusätzlich ein Kreuz darin. [top]/[bottom] sind die Geräte-Y-Koordinaten
|
||||||
|
* der zugehörigen ersten Textzeile - das Kästchen wird vertikal darauf zentriert.
|
||||||
|
*/
|
||||||
|
private fun drawCheckboxGlyph(
|
||||||
|
canvas: Canvas,
|
||||||
|
left: Float,
|
||||||
|
top: Float,
|
||||||
|
bottom: Float,
|
||||||
|
checked: Boolean,
|
||||||
|
paints: ExportPaints,
|
||||||
|
) {
|
||||||
|
val centerY = (top + bottom) / 2f
|
||||||
|
val boxTop = centerY - CHECKBOX_SIZE / 2f
|
||||||
|
val boxBottom = centerY + CHECKBOX_SIZE / 2f
|
||||||
|
val boxRight = left + CHECKBOX_SIZE
|
||||||
|
|
||||||
|
canvas.drawRect(left, boxTop, boxRight, boxBottom, paints.checkboxOutline)
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
val inset = CHECKBOX_SIZE * 0.2f
|
||||||
|
canvas.drawLine(left + inset, boxTop + inset, boxRight - inset, boxBottom - inset, paints.checkboxMark)
|
||||||
|
canvas.drawLine(boxRight - inset, boxTop + inset, left + inset, boxBottom - inset, paints.checkboxMark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt aus einem [PdfExportModel] eine PDF-Datei im internen App-Speicher.
|
||||||
|
*
|
||||||
|
* Bewusst vollständig unabhängig von Compose, Navigation, ViewModels und rohen Room-Entities -
|
||||||
|
* kennt ausschließlich diese schlanke, layoutorientierte Export-Datenstruktur. Als Interface
|
||||||
|
* mit separater Implementierung ([AndroidPdfDocumentExporter]) gehalten, damit später eine
|
||||||
|
* PDF-Engine mit Verschlüsselungs-/Signaturunterstützung ergänzt werden kann (siehe
|
||||||
|
* Aufgabenstellung "Schreibgeschützt"), ohne Repository/ViewModel/UI zu berühren.
|
||||||
|
*/
|
||||||
|
interface DocumentationPdfExporter {
|
||||||
|
suspend fun export(model: PdfExportModel): PdfExportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klarer Erfolg-/Fehlerpfad statt roher, bis in die UI durchgereichter Exceptions. */
|
||||||
|
sealed interface PdfExportResult {
|
||||||
|
data class Success(val file: File) : PdfExportResult
|
||||||
|
data class Failure(val error: PdfExportError) : PdfExportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fachliche Fehlerkategorien für den PDF-Export.
|
||||||
|
*
|
||||||
|
* [DocumentationNotFound] wird NICHT vom Exporter selbst geworfen (der kennt keine
|
||||||
|
* documentationId, nur das fertige [PdfExportModel]), sondern vom aufrufenden Repository, das
|
||||||
|
* die Dokumentation vorher lädt - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.exportPdf].
|
||||||
|
*
|
||||||
|
* Ein eigener Fehlerfall für "Patientendaten fehlen unerwartet" existiert bewusst NICHT: Da
|
||||||
|
* [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot] im Domain-Modell
|
||||||
|
* nicht-nullable ist, macht der Kotlin-Typ diesen Fall bereits zur Kompilierzeit unmöglich - ein
|
||||||
|
* zusätzlicher Laufzeit-Check wäre toter Code.
|
||||||
|
*/
|
||||||
|
sealed interface PdfExportError {
|
||||||
|
data object DocumentationNotFound : PdfExportError
|
||||||
|
data class DirectoryCreationFailed(val path: String) : PdfExportError
|
||||||
|
data class WriteFailed(val message: String) : PdfExportError
|
||||||
|
data class Unexpected(val message: String) : PdfExportError
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
|
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt ein [Documentation]-Domain-Modell in das layoutfertige [PdfExportModel] um.
|
||||||
|
*
|
||||||
|
* Verwendet für die Filterung ausschließlich die bestehende [BlockEditedPolicy] (keine eigene,
|
||||||
|
* abweichende Definition von "bearbeitet") und ausschließlich die historischen Snapshot-Felder
|
||||||
|
* (titleSnapshot/labelSnapshot bzw. deren bereits gemappte Domain-Entsprechungen
|
||||||
|
* [de.medapp.wunddoku.domain.model.DocumentationBlock.title]/[de.medapp.wunddoku.domain.model.CheckItem.label]) -
|
||||||
|
* niemals den aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Ebenso werden
|
||||||
|
* ausschließlich [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot]-Daten
|
||||||
|
* verwendet, nicht der aktuelle Patientendatensatz - eine spätere Änderung der Patienten-
|
||||||
|
* Stammdaten darf ein bereits erzeugtes PDF nicht rückwirkend verändern.
|
||||||
|
*/
|
||||||
|
object DocumentationToPdfExportModelMapper {
|
||||||
|
|
||||||
|
fun map(documentation: Documentation): PdfExportModel {
|
||||||
|
val createdAtDate = documentation.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
val snapshot = documentation.patientSnapshot
|
||||||
|
|
||||||
|
val editedBlocks = documentation.blocks
|
||||||
|
.filter { BlockEditedPolicy.isEdited(it) }
|
||||||
|
.sortedBy { it.order }
|
||||||
|
.map { block ->
|
||||||
|
PdfExportBlock(
|
||||||
|
title = block.title,
|
||||||
|
items = block.items
|
||||||
|
.sortedBy { it.order }
|
||||||
|
.map { item ->
|
||||||
|
PdfExportItem(
|
||||||
|
label = item.label,
|
||||||
|
checked = item.checked,
|
||||||
|
isFreeText = item.isFreeText,
|
||||||
|
freeTextValue = item.freeTextValue,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileName = PdfFileNameGenerator.generate(
|
||||||
|
createdAt = createdAtDate,
|
||||||
|
firstName = snapshot.firstName,
|
||||||
|
lastName = snapshot.lastName,
|
||||||
|
birthDate = snapshot.birthDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PdfExportModel(
|
||||||
|
documentationId = documentation.id,
|
||||||
|
fileName = fileName,
|
||||||
|
patientFullName = snapshot.fullName,
|
||||||
|
patientBirthDate = snapshot.birthDate,
|
||||||
|
patientDisplayAddress = snapshot.displayAddress,
|
||||||
|
createdAt = createdAtDate,
|
||||||
|
blocks = editedBlocks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flache, ausschließlich layoutrelevante Export-Datenstruktur für [DocumentationPdfExporter].
|
||||||
|
*
|
||||||
|
* Bewusst getrennt vom Domain-Modell [de.medapp.wunddoku.domain.model.Documentation]: Der
|
||||||
|
* Exporter soll weder Room-Entities noch domänenspezifische Feinheiten (Snapshot-Mechanik,
|
||||||
|
* IDs, Status etc.) kennen müssen, sondern ausschließlich das, was tatsächlich auf die Seite
|
||||||
|
* gezeichnet wird. Die Umwandlung übernimmt [DocumentationToPdfExportModelMapper].
|
||||||
|
*/
|
||||||
|
data class PdfExportModel(
|
||||||
|
val documentationId: String,
|
||||||
|
val fileName: String,
|
||||||
|
val patientFullName: String,
|
||||||
|
val patientBirthDate: LocalDate,
|
||||||
|
val patientDisplayAddress: String,
|
||||||
|
val createdAt: LocalDate,
|
||||||
|
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
|
||||||
|
val blocks: List<PdfExportBlock>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PdfExportBlock(
|
||||||
|
val title: String,
|
||||||
|
/** Bereits sortiert nach itemOrder. Enthält ALLE Optionen des Blocks (angehakt und nicht
|
||||||
|
* angehakt) - siehe Architekturentscheidung zur Checkbox-Darstellung. */
|
||||||
|
val items: List<PdfExportItem>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PdfExportItem(
|
||||||
|
val label: String,
|
||||||
|
val checked: Boolean,
|
||||||
|
val isFreeText: Boolean,
|
||||||
|
val freeTextValue: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale, reine (keine Android-Abhängigkeit, ohne Instrumentierung testbar) Erzeugung des
|
||||||
|
* vereinbarten PDF-Dateinamensschemas:
|
||||||
|
*
|
||||||
|
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`, z. B.
|
||||||
|
* `2026-08-23_Max_Mustermann_1980-03-14.pdf`
|
||||||
|
*
|
||||||
|
* Alle Bestandteile werden dateisystemkompatibel normalisiert (keine Pfadseparatoren, keine
|
||||||
|
* ungültigen Zeichen, deutsche Umlaute transliteriert statt einfach entfernt).
|
||||||
|
*/
|
||||||
|
object PdfFileNameGenerator {
|
||||||
|
|
||||||
|
fun generate(createdAt: LocalDate, firstName: String, lastName: String, birthDate: LocalDate): String {
|
||||||
|
val datePart = createdAt.toString() // LocalDate.toString() liefert ISO-8601 (yyyy-MM-dd)
|
||||||
|
val birthPart = birthDate.toString()
|
||||||
|
val firstNamePart = normalizeNameComponent(firstName)
|
||||||
|
val lastNamePart = normalizeNameComponent(lastName)
|
||||||
|
return "${datePart}_${firstNamePart}_${lastNamePart}_${birthPart}.pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisiert einen einzelnen Namensbestandteil:
|
||||||
|
* 1. Deutsche Umlaute/ß werden transliteriert (ä→ae usw.), nicht einfach entfernt - sonst
|
||||||
|
* würden z. B. "Müller" und "Mller" im Dateinamen ununterscheidbar.
|
||||||
|
* 2. Verbleibende Diakritika (andere Sprachen) werden über Unicode-Normalisierung entfernt.
|
||||||
|
* 3. Leerzeichen werden zu Unterstrichen, alles außerhalb `[A-Za-z0-9_-]` wird entfernt -
|
||||||
|
* schließt insbesondere Pfadseparatoren (`/`, `\`) und sonstige für Dateinamen
|
||||||
|
* problematische Zeichen sicher aus.
|
||||||
|
* 4. Mehrfache Unterstriche werden zusammengefasst, führende/nachgestellte entfernt.
|
||||||
|
*/
|
||||||
|
private fun normalizeNameComponent(input: String): String {
|
||||||
|
val transliterated = transliterateGerman(input)
|
||||||
|
val withoutDiacritics = Normalizer.normalize(transliterated, Normalizer.Form.NFD)
|
||||||
|
.replace(Regex("\\p{M}+"), "")
|
||||||
|
val safe = withoutDiacritics
|
||||||
|
.replace(Regex("\\s+"), "_")
|
||||||
|
.replace(Regex("[^A-Za-z0-9_-]"), "")
|
||||||
|
.replace(Regex("_+"), "_")
|
||||||
|
.trim('_', '-')
|
||||||
|
return safe.ifBlank { "Unbekannt" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transliterateGerman(input: String): String = input
|
||||||
|
.replace("ä", "ae").replace("Ä", "Ae")
|
||||||
|
.replace("ö", "oe").replace("Ö", "Oe")
|
||||||
|
.replace("ü", "ue").replace("Ü", "Ue")
|
||||||
|
.replace("ß", "ss")
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.pdf.PdfDocument
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.StaticLayout
|
||||||
|
import android.text.TextPaint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet Seiten, Zeichenfläche und die aktuelle vertikale Schreibposition ("Cursor") beim
|
||||||
|
* Erzeugen eines [PdfDocument]. Kapselt die komplette Seitenumbruch-Logik an einer Stelle, damit
|
||||||
|
* [AndroidPdfDocumentExporter] selbst nur noch "was" gezeichnet werden soll beschreiben muss.
|
||||||
|
*
|
||||||
|
* SEITENUMBRUCH-STRATEGIE: [drawText] zerlegt einen Text NICHT durch manuelles Verschieben/
|
||||||
|
* Clippen einzelner Zeilen eines einmal gebauten [StaticLayout]. Stattdessen wird pro
|
||||||
|
* Seiten-Abschnitt ein EIGENES, frisch aufgebautes [StaticLayout] für genau den Textausschnitt
|
||||||
|
* erzeugt, der auf die aktuelle Seite passt (Ausschnittgrenzen liegen dabei immer exakt auf
|
||||||
|
* bereits von Android selbst bestimmten Zeilengrenzen). Jeder Seiten-Abschnitt berechnet damit
|
||||||
|
* seine Baselines/Zeilenhöhen selbst und korrekt aus der StaticLayout-API - es gibt keine
|
||||||
|
* manuelle Koordinatenverschiebung einzelner Zeilen, die zu Baseline-/Zeilenhöhenfehlern führen
|
||||||
|
* könnte. Das erlaubt einen Seitenumbruch exakt zwischen zwei Zeilen und behandelt "Block
|
||||||
|
* größer als eine Seite" automatisch mit, ohne Sonderfall-Code.
|
||||||
|
*/
|
||||||
|
internal class PdfPageCursor(
|
||||||
|
private val pdfDocument: PdfDocument,
|
||||||
|
private val pageWidth: Int,
|
||||||
|
private val pageHeight: Int,
|
||||||
|
private val marginLeft: Float,
|
||||||
|
private val marginRight: Float,
|
||||||
|
private val marginTop: Float,
|
||||||
|
private val marginBottom: Float,
|
||||||
|
) {
|
||||||
|
private var pageNumber = 0
|
||||||
|
private var currentPage: PdfDocument.Page? = null
|
||||||
|
private var canvas: Canvas? = null
|
||||||
|
private var cursorY: Float = marginTop
|
||||||
|
|
||||||
|
private val dividerPaint = Paint().apply {
|
||||||
|
color = Color.LTGRAY
|
||||||
|
strokeWidth = 1f
|
||||||
|
}
|
||||||
|
private val footerPaint = Paint().apply {
|
||||||
|
color = Color.GRAY
|
||||||
|
textSize = 9f
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentWidth: Int
|
||||||
|
get() = (pageWidth - marginLeft - marginRight).toInt()
|
||||||
|
|
||||||
|
private fun contentBottom(): Float = pageHeight - marginBottom
|
||||||
|
|
||||||
|
private fun startIfNeeded() {
|
||||||
|
if (currentPage == null) startNewPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startNewPage() {
|
||||||
|
finishCurrentPageIfOpen()
|
||||||
|
pageNumber++
|
||||||
|
val info = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()
|
||||||
|
val page = pdfDocument.startPage(info)
|
||||||
|
currentPage = page
|
||||||
|
canvas = page.canvas
|
||||||
|
cursorY = marginTop
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishCurrentPageIfOpen() {
|
||||||
|
val page = currentPage ?: return
|
||||||
|
// Seitenzahl wird direkt beim Fertigstellen dieser Seite gezeichnet (kein "Seite X von
|
||||||
|
// Y", da die Gesamtseitenzahl an dieser Stelle noch nicht bekannt ist).
|
||||||
|
canvas?.drawText("Seite $pageNumber", pageWidth / 2f, pageHeight - marginBottom / 2f, footerPaint)
|
||||||
|
pdfDocument.finishPage(page)
|
||||||
|
currentPage = null
|
||||||
|
canvas = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal
|
||||||
|
* aufgerufen werden. */
|
||||||
|
fun finish() {
|
||||||
|
finishCurrentPageIfOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drawDivider(spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
|
||||||
|
startIfNeeded()
|
||||||
|
ensureSpace(spacingBefore + 1f)
|
||||||
|
cursorY += spacingBefore
|
||||||
|
canvas!!.drawLine(marginLeft, cursorY, pageWidth - marginRight, cursorY, dividerPaint)
|
||||||
|
cursorY += 1f + spacingAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserviert [height] vertikalen Platz, beginnt bei Bedarf eine neue Seite. */
|
||||||
|
private fun ensureSpace(height: Float) {
|
||||||
|
startIfNeeded()
|
||||||
|
if (cursorY + height > contentBottom()) {
|
||||||
|
startNewPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert
|
||||||
|
* über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach
|
||||||
|
* rechts (z. B. für eingerückten Freitext unter einer Checkbox-Zeile).
|
||||||
|
*
|
||||||
|
* [leadingMark], falls gesetzt, wird EINMALIG unmittelbar vor der allerersten gezeichneten
|
||||||
|
* Textzeile aufgerufen - Aufrufparameter sind (Canvas, linke X-Position vor dem Einzug,
|
||||||
|
* Geräte-Y-Oberkante, Geräte-Y-Unterkante) der ersten Zeile. Dient z. B. dazu, links neben
|
||||||
|
* dem Text ein Checkbox-Symbol zu zeichnen (s. [AndroidPdfDocumentExporter]). Kästchen und
|
||||||
|
* erste Zeile werden dabei als untrennbare Einheit behandelt: Reicht der Platz auf der
|
||||||
|
* aktuellen Seite nicht einmal für die erste Zeile, wird vorher auf eine neue Seite
|
||||||
|
* gewechselt - das Kästchen kann also niemals von seiner Zeile getrennt auf eine andere
|
||||||
|
* Seite geraten.
|
||||||
|
*/
|
||||||
|
fun drawText(
|
||||||
|
text: String,
|
||||||
|
paint: TextPaint,
|
||||||
|
indent: Float = 0f,
|
||||||
|
spacingAfter: Float = 0f,
|
||||||
|
leadingMark: ((canvas: Canvas, left: Float, top: Float, bottom: Float) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
|
||||||
|
val fullLayout = buildLayout(text, paint, width)
|
||||||
|
var lineIndex = 0
|
||||||
|
val lineCount = fullLayout.lineCount
|
||||||
|
var isFirstLine = true
|
||||||
|
|
||||||
|
while (lineIndex < lineCount) {
|
||||||
|
startIfNeeded()
|
||||||
|
val remaining = contentBottom() - cursorY
|
||||||
|
|
||||||
|
// Ermittelt, wie viele aufeinanderfolgende Zeilen ab lineIndex noch auf die
|
||||||
|
// aktuelle Seite passen (Ausschnittgrenzen liegen exakt auf Zeilengrenzen).
|
||||||
|
var endLineExclusive = lineIndex
|
||||||
|
var usedHeight = 0f
|
||||||
|
while (endLineExclusive < lineCount) {
|
||||||
|
val lineHeight = (fullLayout.getLineBottom(endLineExclusive) - fullLayout.getLineTop(endLineExclusive)).toFloat()
|
||||||
|
if (usedHeight + lineHeight > remaining) break
|
||||||
|
usedHeight += lineHeight
|
||||||
|
endLineExclusive++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endLineExclusive == lineIndex) {
|
||||||
|
// Nicht einmal eine einzige Zeile passt mehr auf die aktuelle Seite -> neue
|
||||||
|
// Seite beginnen und denselben Ausschnitt erneut versuchen.
|
||||||
|
startNewPage()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val charStart = fullLayout.getLineStart(lineIndex)
|
||||||
|
val charEnd = fullLayout.getLineEnd(endLineExclusive - 1)
|
||||||
|
// Frisches StaticLayout NUR für diesen Seiten-Abschnitt - Android berechnet Zeilen/
|
||||||
|
// Baselines dafür vollständig neu, beginnend bei lokal y = 0 für die erste Zeile
|
||||||
|
// dieses Abschnitts. Keine manuelle Koordinatenübernahme aus fullLayout nötig.
|
||||||
|
val chunkLayout = buildLayout(text.substring(charStart, charEnd), paint, width)
|
||||||
|
|
||||||
|
val c = canvas!!
|
||||||
|
c.save()
|
||||||
|
c.translate(marginLeft + indent, cursorY)
|
||||||
|
chunkLayout.draw(c)
|
||||||
|
c.restore()
|
||||||
|
|
||||||
|
if (isFirstLine && leadingMark != null) {
|
||||||
|
val firstLineHeight = (chunkLayout.getLineBottom(0) - chunkLayout.getLineTop(0)).toFloat()
|
||||||
|
leadingMark(c, marginLeft, cursorY, cursorY + firstLineHeight)
|
||||||
|
isFirstLine = false
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorY += usedHeight
|
||||||
|
lineIndex = endLineExclusive
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorY += spacingAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLayout(text: String, paint: TextPaint, width: Int): StaticLayout {
|
||||||
|
val safeWidth = width.coerceAtLeast(1)
|
||||||
|
return StaticLayout.Builder.obtain(text, 0, text.length, paint, safeWidth)
|
||||||
|
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||||
|
.setLineSpacing(0f, 1.05f)
|
||||||
|
.setIncludePad(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-3
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.BrokenImage
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||||
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
|
||||||
@@ -37,6 +38,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
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
|
||||||
@@ -64,6 +67,7 @@ import de.medapp.wunddoku.R
|
|||||||
import de.medapp.wunddoku.domain.model.CheckItem
|
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 kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
||||||
@@ -109,7 +113,19 @@ fun DocumentationEditScreen(
|
|||||||
contract = ActivityResultContracts.PickMultipleVisualMedia(),
|
contract = ActivityResultContracts.PickMultipleVisualMedia(),
|
||||||
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
|
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
|
||||||
|
|
||||||
|
// Für die kurze Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (rein zum manuellen
|
||||||
|
// Testen von PDF-001/002 - Öffnen/Teilen der Datei folgt erst in PDF-005/006).
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(uiState.pdfExportMessage) {
|
||||||
|
val message = uiState.pdfExportMessage
|
||||||
|
if (message != null) {
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
viewModel.dismissPdfExportMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -126,9 +142,18 @@ fun DocumentationEditScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// PDF-Export/Unterschrift folgen in einer späteren Phase. "Fertig" beendet
|
// PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt
|
||||||
// die Bearbeitung bereits jetzt (Inhalte sind ohnehin per Autosave
|
// ausschließlich die Datei im internen Speicher (PDF-001/002), als
|
||||||
// gesichert, kein expliziter Speichern-Button nötig).
|
// minimaler Trigger zum manuellen Testen der Export-Funktionalität.
|
||||||
|
if (uiState.pdfExportInProgress) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = viewModel::exportPdf) {
|
||||||
|
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
|
||||||
|
// Autosave gesichert, kein expliziter Speichern-Button nötig).
|
||||||
Button(onClick = onFinished) { Text("Fertig") }
|
Button(onClick = onFinished) { Text("Fertig") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+41
@@ -10,6 +10,8 @@ import androidx.navigation.toRoute
|
|||||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||||
import de.medapp.wunddoku.di.wunddokuApplication
|
import de.medapp.wunddoku.di.wunddokuApplication
|
||||||
import de.medapp.wunddoku.domain.model.Documentation
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
|
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||||
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
|
||||||
@@ -32,6 +34,11 @@ data class DocumentationEditUiState(
|
|||||||
val photoImportError: String? = null,
|
val photoImportError: String? = null,
|
||||||
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
|
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
|
||||||
val pendingDeletePhotoId: String? = null,
|
val pendingDeletePhotoId: String? = null,
|
||||||
|
/** true, während ein PDF-Export läuft (Ladeindikator, Button währenddessen deaktiviert). */
|
||||||
|
val pdfExportInProgress: Boolean = false,
|
||||||
|
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
|
||||||
|
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
|
||||||
|
val pdfExportMessage: String? = null,
|
||||||
) {
|
) {
|
||||||
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
||||||
}
|
}
|
||||||
@@ -221,6 +228,40 @@ class DocumentationEditViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst den PDF-Export an (PDF-001/002). Rein zum manuellen Testen der Export-Funktionalität
|
||||||
|
* gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit
|
||||||
|
* nicht Teil dieses Schritts, die erzeugte Datei liegt ausschließlich im internen
|
||||||
|
* App-Speicher. Keine rohe Exception erreicht die UI: [PdfExportResult] wird hier in eine
|
||||||
|
* kurze, verständliche Meldung übersetzt.
|
||||||
|
*/
|
||||||
|
fun exportPdf() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(pdfExportInProgress = true, pdfExportMessage = null) }
|
||||||
|
val result = repository.exportPdf(documentationId)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pdfExportInProgress = false,
|
||||||
|
pdfExportMessage = when (result) {
|
||||||
|
is PdfExportResult.Success -> "PDF erzeugt: ${result.file.name}"
|
||||||
|
is PdfExportResult.Failure -> "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissPdfExportMessage() {
|
||||||
|
_uiState.update { it.copy(pdfExportMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PdfExportError.toUserMessage(): String = when (this) {
|
||||||
|
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
|
||||||
|
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
|
||||||
|
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
|
||||||
|
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
|
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
|
||||||
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
|
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
|
||||||
|
|||||||
Reference in New Issue
Block a user