PDF/MEDIA #5
@@ -25,6 +25,10 @@ import de.medapp.wunddoku.domain.model.Photo
|
||||
import de.medapp.wunddoku.domain.model.Signature
|
||||
import de.medapp.wunddoku.media.ImageDownsampler
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
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
|
||||
* 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(
|
||||
private val documentationDao: DocumentationDao,
|
||||
@@ -49,6 +57,7 @@ class DocumentationRepository(
|
||||
private val patientDao: PatientDao,
|
||||
private val photoStorage: PhotoStorage,
|
||||
private val signatureFileStorage: SignatureFileStorage,
|
||||
private val pdfExporter: DocumentationPdfExporter,
|
||||
private val db: AppDatabase,
|
||||
) {
|
||||
|
||||
@@ -113,8 +122,10 @@ class DocumentationRepository(
|
||||
* - status: IN_PROGRESS
|
||||
* - continuedFromDocumentationId: ID der Quelle
|
||||
* - Unterschriften: werden NICHT kopiert
|
||||
* - Fotos: über [copyPhotos] steuerbar, Standard `false` - fachlich noch nicht final
|
||||
* entschieden
|
||||
* - Fotos: werden standardmäßig mit übernommen (physisch eigenständige Kopien pro
|
||||
* 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(
|
||||
sourceDocumentationId: String,
|
||||
@@ -255,6 +266,21 @@ class DocumentationRepository(
|
||||
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
|
||||
* 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.media.InternalPhotoStorage
|
||||
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.SignatureFileStorage
|
||||
|
||||
@@ -27,6 +29,7 @@ class AppContainer(context: Context) {
|
||||
|
||||
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
|
||||
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) }
|
||||
private val pdfExporter: DocumentationPdfExporter by lazy { AndroidPdfDocumentExporter(context) }
|
||||
|
||||
val patientRepository: PatientRepository by lazy {
|
||||
PatientRepository(patientDao = database.patientDao())
|
||||
@@ -42,6 +45,7 @@ class AppContainer(context: Context) {
|
||||
patientDao = database.patientDao(),
|
||||
photoStorage = photoStorage,
|
||||
signatureFileStorage = signatureFileStorage,
|
||||
pdfExporter = pdfExporter,
|
||||
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.BrokenImage
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
@@ -37,6 +38,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.DocumentationBlock
|
||||
import de.medapp.wunddoku.domain.model.Photo
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
||||
@@ -109,7 +113,19 @@ fun DocumentationEditScreen(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(),
|
||||
) { 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(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@@ -126,9 +142,18 @@ fun DocumentationEditScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// PDF-Export/Unterschrift folgen in einer späteren Phase. "Fertig" beendet
|
||||
// die Bearbeitung bereits jetzt (Inhalte sind ohnehin per Autosave
|
||||
// gesichert, kein expliziter Speichern-Button nötig).
|
||||
// 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.
|
||||
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") }
|
||||
},
|
||||
)
|
||||
|
||||
+41
@@ -10,6 +10,8 @@ import androidx.navigation.toRoute
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
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 kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -32,6 +34,11 @@ data class DocumentationEditUiState(
|
||||
val photoImportError: String? = null,
|
||||
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
|
||||
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
|
||||
}
|
||||
@@ -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.
|
||||
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
|
||||
|
||||
Reference in New Issue
Block a user