diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt index 70393f1..7f4446a 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt @@ -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 diff --git a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt index 0d3c714..f16d544 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -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, ) } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt new file mode 100644 index 0000000..f48e350 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -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//.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) + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt new file mode 100644 index 0000000..7f011c6 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt @@ -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 +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt new file mode 100644 index 0000000..d8cff06 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt new file mode 100644 index 0000000..6079dec --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt @@ -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, +) + +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, +) + +data class PdfExportItem( + val label: String, + val checked: Boolean, + val isFreeText: Boolean, + val freeTextValue: String, +) diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfFileNameGenerator.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfFileNameGenerator.kt new file mode 100644 index 0000000..e17cf60 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfFileNameGenerator.kt @@ -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") +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt new file mode 100644 index 0000000..e745a7f --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt @@ -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() + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index ca57317..51bcef4 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt @@ -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 -> 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") } }, ) diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index 8c1af9a..0758ea0 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -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