From ebf85df1885dc69f1e4457a192dd0403220c8994 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Tue, 25 Aug 2026 01:27:29 +0200 Subject: [PATCH 1/4] PDF-001 & PDF-002 (ungetestet) --- .../repository/DocumentationRepository.kt | 30 ++- .../de/medapp/wunddoku/di/AppContainer.kt | 4 + .../pdf/AndroidPdfDocumentExporter.kt | 236 ++++++++++++++++++ .../wunddoku/pdf/DocumentationPdfExporter.kt | 41 +++ .../DocumentationToPdfExportModelMapper.kt | 61 +++++ .../de/medapp/wunddoku/pdf/PdfExportModel.kt | 36 +++ .../wunddoku/pdf/PdfFileNameGenerator.kt | 53 ++++ .../de/medapp/wunddoku/pdf/PdfPageCursor.kt | 185 ++++++++++++++ .../edit/DocumentationEditScreen.kt | 31 ++- .../edit/DocumentationEditViewModel.kt | 41 +++ 10 files changed, 713 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/PdfFileNameGenerator.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt 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 From 8c05a513fe5f6d070ccef8c7cea081f28dfece0b Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Tue, 25 Aug 2026 09:50:31 +0200 Subject: [PATCH 2/4] PDF-005 --- .../java/de/medapp/wunddoku/pdf/PdfOpener.kt | 89 +++++++++++++++++++ .../edit/DocumentationEditScreen.kt | 33 +++++-- .../edit/DocumentationEditViewModel.kt | 33 +++++-- 3 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt new file mode 100644 index 0000000..ba4a9f5 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt @@ -0,0 +1,89 @@ +package de.medapp.wunddoku.pdf + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** + * Öffnet eine bereits erzeugte PDF-Datei im auf dem Gerät verfügbaren Standard-PDF-Reader + * (PDF-005). Bewusst getrennt von [DocumentationPdfExporter]: PDF-Erzeugung (reine + * Datei-/Domain-Logik) und PDF-Anzeige (Android-Intent/Activity-Start) bleiben unterschiedliche + * Verantwortlichkeiten - der Exporter startet niemals eine Activity. + * + * VulneraDoc implementiert ausdrücklich KEINEN eigenen PDF-Viewer; welche konkrete App den + * `ACTION_VIEW`-Intent verarbeitet, entscheidet ausschließlich Android/der Nutzer. + */ +interface PdfOpener { + fun open(file: File): PdfOpenResult +} + +sealed interface PdfOpenResult { + data object Success : PdfOpenResult + + sealed interface Failure : PdfOpenResult { + /** Die referenzierte Datei existiert nicht mehr (z. B. zwischenzeitlich extern entfernt). */ + data object FileNotFound : Failure + + /** FileProvider konnte für die Datei keine content://-Uri erzeugen. */ + data class UriResolutionFailed(val message: String) : Failure + + /** Kein installierter Reader kann application/pdf verarbeiten. */ + data object NoViewerAvailable : Failure + + data class Unexpected(val message: String) : Failure + } +} + +/** Fachliche, für die Anzeige (z. B. Snackbar) geeignete Übersetzung eines [PdfOpenResult.Failure]. */ +fun PdfOpenResult.Failure.toUserMessage(): String = when (this) { + PdfOpenResult.Failure.FileNotFound -> + "Die PDF-Datei wurde nicht gefunden. Bitte die Dokumentation erneut exportieren." + is PdfOpenResult.Failure.UriResolutionFailed -> + "Die PDF-Datei konnte nicht zur Anzeige freigegeben werden." + PdfOpenResult.Failure.NoViewerAvailable -> + "Auf diesem Gerät ist keine App zum Anzeigen von PDF-Dateien installiert." + is PdfOpenResult.Failure.Unexpected -> + "Die PDF-Datei konnte nicht geöffnet werden." +} + +class AndroidPdfOpener(private val context: Context) : PdfOpener { + + override fun open(file: File): PdfOpenResult { + if (!file.exists()) { + return PdfOpenResult.Failure.FileNotFound + } + + val authority = "${context.packageName}.fileprovider" + val uri = try { + FileProvider.getUriForFile(context, authority, file) + } catch (e: IllegalArgumentException) { + // Wird von FileProvider geworfen, wenn die Datei außerhalb der in file_paths.xml + // freigegebenen Verzeichnisse liegt oder die Authority nicht passt. + return PdfOpenResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.") + } + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/pdf") + // Ausschließlich Lese-, KEINE Schreibberechtigung für den externen Reader. + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + return try { + context.startActivity(intent) + PdfOpenResult.Success + } catch (e: ActivityNotFoundException) { + // Bewusst KEIN vorheriges intent.resolveActivity(...) - das würde auf Android 11+ + // durch Package-Visibility-Einschränkungen ohne zusätzliche -Deklaration + // im Manifest fälschlich "kein Reader" melden können, auch wenn tatsächlich einer + // installiert ist. Der eigentliche startActivity()-Aufruf unterliegt dieser + // Einschränkung nicht - ActivityNotFoundException ist hier die zuverlässige Quelle. + PdfOpenResult.Failure.NoViewerAvailable + } catch (e: SecurityException) { + PdfOpenResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.") + } catch (e: Exception) { + PdfOpenResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Öffnen der PDF-Datei.") + } + } +} 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 51bcef4..2253187 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 @@ -40,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -56,6 +57,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -67,7 +69,9 @@ 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 +import de.medapp.wunddoku.pdf.AndroidPdfOpener +import de.medapp.wunddoku.pdf.PdfOpenResult +import de.medapp.wunddoku.pdf.toUserMessage /** * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit @@ -113,15 +117,30 @@ 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). + // Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler + // "ANZEIGEN"-Aktion (PDF-005). PdfOpener wird bewusst hier in der Compose-Schicht instanziiert + // (mit dem Activity-Context aus LocalContext, analog zum Photo-Picker-Launcher oben) - das + // Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe. + val context = LocalContext.current + val pdfOpener = remember(context) { AndroidPdfOpener(context) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(uiState.pdfExportMessage) { - val message = uiState.pdfExportMessage - if (message != null) { - snackbarHostState.showSnackbar(message) - viewModel.dismissPdfExportMessage() + val message = uiState.pdfExportMessage ?: return@LaunchedEffect + val exportedFile = uiState.lastExportedPdfFile + // "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte + // Datei vorliegt - bei einem fehlgeschlagenen Export ist exportedFile null und die + // Snackbar zeigt keine Aktion an. + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = if (exportedFile != null) "ANZEIGEN" else null, + ) + if (result == SnackbarResult.ActionPerformed && exportedFile != null) { + when (val openResult = pdfOpener.open(exportedFile)) { + PdfOpenResult.Success -> Unit + is PdfOpenResult.Failure -> snackbarHostState.showSnackbar(openResult.toUserMessage()) + } } + viewModel.dismissPdfExportMessage() } Scaffold( 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 0758ea0..18b97ab 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 @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.io.File data class DocumentationEditUiState( val documentation: Documentation? = null, @@ -39,6 +40,14 @@ data class DocumentationEditUiState( /** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als * Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */ val pdfExportMessage: String? = null, + /** + * Die zuletzt erfolgreich erzeugte PDF-Datei dieser Dokumentation (PDF-005: "ANZEIGEN" in + * der Snackbar). `null` sowohl vor dem ersten Export als auch nach einem fehlgeschlagenen + * Export - "ANZEIGEN" wird nur angeboten, wenn hier tatsächlich eine Datei vorliegt. Wird + * bei jedem neuen Exportversuch zunächst zurückgesetzt, damit niemals eine veraltete Datei + * referenziert werden kann, während ein neuer Export läuft. + */ + val lastExportedPdfFile: File? = null, ) { val blockCount: Int get() = documentation?.blocks?.size ?: 0 } @@ -237,16 +246,24 @@ class DocumentationEditViewModel( */ fun exportPdf() { viewModelScope.launch { - _uiState.update { it.copy(pdfExportInProgress = true, pdfExportMessage = null) } + _uiState.update { + it.copy(pdfExportInProgress = true, pdfExportMessage = null, lastExportedPdfFile = 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()}" - }, - ) + when (result) { + is PdfExportResult.Success -> it.copy( + pdfExportInProgress = false, + pdfExportMessage = "PDF erfolgreich erstellt", + lastExportedPdfFile = result.file, + ) + + is PdfExportResult.Failure -> it.copy( + pdfExportInProgress = false, + pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}", + lastExportedPdfFile = null, + ) + } } } } From 91e68c827a6da75b47d2133225088b10001f9fd2 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Thu, 27 Aug 2026 08:10:18 +0200 Subject: [PATCH 3/4] PDF-004 --- .../de/medapp/wunddoku/di/AppContainer.kt | 2 +- .../de/medapp/wunddoku/media/PhotoStorage.kt | 77 ++++++++++++ .../pdf/AndroidPdfDocumentExporter.kt | 118 +++++++++++++++++- .../DocumentationToPdfExportModelMapper.kt | 9 ++ .../de/medapp/wunddoku/pdf/PdfExportModel.kt | 11 ++ .../de/medapp/wunddoku/pdf/PdfPageCursor.kt | 46 +++++++ 6 files changed, 259 insertions(+), 4 deletions(-) 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 f16d544..ab99822 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -29,7 +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) } + private val pdfExporter: DocumentationPdfExporter by lazy { AndroidPdfDocumentExporter(context, photoStorage) } val patientRepository: PatientRepository by lazy { PatientRepository(patientDao = database.patientDao()) diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt index 43db5cc..e6fb579 100644 --- a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.util.UUID +import kotlin.math.roundToInt /** * Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation: @@ -86,6 +87,27 @@ interface PhotoStorage { * wenn eine Datei nachträglich außerhalb der App entfernt wurde). */ suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? + + /** + * Decodiert die gespeicherte Datei proportional eingepasst in eine Box von höchstens + * [maxWidthPx] × [maxHeightPx] Pixeln, OHNE Hochskalieren über die native Auflösung hinaus + * (PDF-004: Fotoanhang). Läuft zweistufig: + * + * 1. Grobe, speicherschonende Decodierung über `BitmapFactory.Options.inSampleSize` + * (Zweierpotenz-Downsampling direkt im Decoder) - liefert ein Bitmap, das mindestens so + * groß wie die exakte Zielgröße ist, aber wegen der Zweierpotenz-Beschränkung von + * `inSampleSize` bei Quellbildern nahe der Zielgröße (z. B. nahe dem Import-Limit + * [ImageDownsampler.MAX_STORED_DIMENSION_PX]) oft noch keine ausreichende Reduktion + * bewirkt. + * 2. Exakte Skalierung dieses groben Zwischenergebnisses auf die tatsächliche Zielgröße + * (`Bitmap.createScaledBitmap`). Das Zwischenergebnis aus Schritt 1 wird direkt danach + * freigegeben (`recycle()`) - das ist unproblematisch, da es zu diesem Zeitpunkt noch + * nirgends (insbesondere nicht an `PdfDocument`) weitergereicht wurde. Nur das Ergebnis + * aus Schritt 2 wird von der aufrufenden Stelle tatsächlich in die PDF gezeichnet. + * + * Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz). + */ + suspend fun decodeFitted(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? } class InternalPhotoStorage(private val context: Context) : PhotoStorage { @@ -212,4 +234,59 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage { ) }.getOrNull() } + + override suspend fun decodeFitted( + documentationId: String, + fileName: String, + maxWidthPx: Int, + maxHeightPx: Int, + ): Bitmap? = withContext(Dispatchers.IO) { + val file = resolve(documentationId, fileName) + if (!file.exists()) return@withContext null + + runCatching { + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, boundsOptions) + val sourceWidth = boundsOptions.outWidth + val sourceHeight = boundsOptions.outHeight + if (sourceWidth <= 0 || sourceHeight <= 0) return@runCatching null + + // Zielgröße: proportionale Einpassung in (maxWidthPx x maxHeightPx), Faktor auf + // maximal 1f begrenzt - kein Hochskalieren über die native Auflösung hinaus. + val scale = minOf( + maxWidthPx.toFloat() / sourceWidth, + maxHeightPx.toFloat() / sourceHeight, + 1f, + ) + val targetWidth = (sourceWidth * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (sourceHeight * scale).roundToInt().coerceAtLeast(1) + + // Schritt 1: grobe, speicherschonende Decodierung über inSampleSize - liefert ein + // Bitmap, das mindestens so groß wie (targetWidth, targetHeight) ist. + val inSampleSize = ImageDownsampler.computeInSampleSize( + sourceWidth, + sourceHeight, + maxOf(targetWidth, targetHeight), + ) + val decodeOptions = BitmapFactory.Options().apply { + this.inSampleSize = inSampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val coarseBitmap = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) + ?: return@runCatching null + + // Schritt 2: exakte Zielgröße. Nur nötig, wenn inSampleSize (Zweierpotenz) nicht + // bereits exakt getroffen hat - insbesondere bei Quellbildern nahe dem + // Import-Limit oft der Fall, da dort inSampleSize = 1 bleibt. + if (coarseBitmap.width == targetWidth && coarseBitmap.height == targetHeight) { + coarseBitmap + } else { + val precise = Bitmap.createScaledBitmap(coarseBitmap, targetWidth, targetHeight, true) + // Das grobe Zwischenergebnis wurde noch nirgends (insbesondere nicht an + // PdfDocument) weitergereicht - Freigabe an dieser Stelle ist unproblematisch. + if (precise !== coarseBitmap) coarseBitmap.recycle() + precise + } + }.getOrNull() + } } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt index f48e350..9812652 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -1,11 +1,13 @@ package de.medapp.wunddoku.pdf import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.pdf.PdfDocument import android.text.TextPaint +import de.medapp.wunddoku.media.PhotoStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -22,11 +24,30 @@ import java.time.format.FormatStyle * reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert * ist bzw. welche Unicode-Symbole sie abdeckt. * + * PDF-004 (Fotoanhang) - wichtige, geprüfte Speicher-Einschränkung von `PdfDocument`: Jede + * `Page` wird intern über ein `SkPicture` AUFGEZEICHNET (verifiziert anhand des AOSP- + * Quellcodes, `frameworks/base/.../PdfDocument.cpp`: `startPage()` -> `mPicture->beginRecording()`, + * `finishPage()` -> nur `endRecording()`, tatsächliches PDF-Rendering inkl. Bild-Einbettung + * erst in `write()`/`writeTo()` über `canvas->drawPicture(*page->mPicture)`). Das bedeutet: JEDE + * über `Canvas.drawBitmap(...)` gezeichnete Bitmap muss bis zum Abschluss von `writeTo()` + * gültig (nicht recycelt) bleiben - ein `Bitmap.recycle()` direkt nach dem Zeichnen einer + * einzelnen Fotoseite ist NICHT sicher. Deshalb werden alle tatsächlich gezeichneten + * Foto-Bitmaps in [export] gesammelt und erst NACH `writeTo()` (im `finally`-Block) + * freigegeben - siehe [retainedPhotoBitmaps]. + * + * Der einzige verbleibende Hebel gegen hohen Speicherverbrauch ist deshalb die Zielauflösung + * pro Foto, nicht der Recycling-Zeitpunkt: Zwischenergebnisse, die selbst nie an + * `PdfDocument`/`Canvas.drawBitmap` übergeben werden (grobe `inSampleSize`-Decodierung vor der + * exakten Zielgrößen-Skalierung, s. [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]), + * dürfen dagegen sofort freigegeben werden. + * * Speicherort: `filesDir/pdf//.pdf` - ausschließlich intern, keine - * MediaStore-Nutzung, kein öffentlicher Speicher (PDF-005/006 mit FileProvider/Sharesheet folgen - * später). + * MediaStore-Nutzung, kein öffentlicher Speicher. */ -class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPdfExporter { +class AndroidPdfDocumentExporter( + private val context: Context, + private val photoStorage: PhotoStorage, +) : DocumentationPdfExporter { companion object { // A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm. @@ -47,6 +68,32 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd private const val CHECKBOX_GAP = 6f private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP + /** + * Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und + * Speicherverbrauch gewählt (freigegeben nach expliziter Prüfung der `PdfDocument`- + * Speichersemantik, s. Klassendokumentation): untere Grenze des ursprünglich + * vorgeschlagenen 150-200-DPI-Bereichs, da ALLE Foto-Bitmaps bis `writeTo()` gleichzeitig + * im Speicher gehalten werden müssen (keine echte "ein Bitmap zur Zeit"-Verarbeitung mit + * dieser PDF-Engine möglich) und `ARGB_8888` statt `RGB_565` verwendet wird (keine + * Farbtiefenreduktion bei medizinischen Wundfotos). + */ + private const val PHOTO_TARGET_DPI = 150 + + // Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe + // Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese + // Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal + // eingelesen wird), nicht die exakte Zeichenposition (die hängt zusätzlich von der + // jeweils bereits gezeichneten Bildunterschrift ab und wird von PdfPageCursor selbst + // anhand des tatsächlich verbleibenden Platzes bestimmt). + private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN + private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN + + /** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */ + private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt() + + /** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */ + private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt() + private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @@ -63,6 +110,12 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd val tempFile = File(targetDir, "${model.fileName}.tmp") val pdfDocument = PdfDocument() + // Sammelt AUSSCHLIESSLICH die Bitmaps, die tatsächlich per Canvas.drawBitmap() gezeichnet + // wurden (nicht die groben inSampleSize-Zwischenergebnisse, die bereits innerhalb von + // PhotoStorage.decodeFitted() freigegeben werden). Diese müssen laut der in der + // Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig + // bleiben und werden deshalb erst im finally-Block unten recycelt. + val retainedPhotoBitmaps = mutableListOf() try { val paints = ExportPaints() val cursor = PdfPageCursor( @@ -79,6 +132,9 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd for (block in model.blocks) { drawBlock(cursor, block, paints) } + for ((index, photo) in model.photos.withIndex()) { + drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps) + } cursor.finish() FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) } @@ -99,6 +155,10 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd ) } finally { pdfDocument.close() + // Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder + // bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist - + // in beiden Fällen werden diese Bitmaps nicht mehr gebraucht. + retainedPhotoBitmaps.forEach { runCatching { it.recycle() } } } } @@ -151,6 +211,13 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd strokeCap = Paint.Cap.ROUND color = Color.BLACK } + + /** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für + * weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */ + val photo = Paint().apply { + isAntiAlias = true + isFilterBitmap = true + } } private fun drawHeader(cursor: PdfPageCursor, model: PdfExportModel, paints: ExportPaints) { @@ -233,4 +300,49 @@ class AndroidPdfDocumentExporter(private val context: Context) : DocumentationPd canvas.drawLine(boxRight - inset, boxTop + inset, left + inset, boxBottom - inset, paints.checkboxMark) } } + + /** + * Zeichnet den Fotoanhang: jedes Foto auf einer eigenen, neu begonnenen Seite (PDF-004). + * Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt + * zusätzlich eine neutrale Nummerierung ("Foto N von M") - keine Patientendaten oder + * ursprünglichen Dateinamen als Beschriftung. + * + * Erfolgreich decodierte Bitmaps werden in [retainedPhotoBitmaps] gesammelt (s. Begründung + * in der Klassendokumentation zur PdfDocument-Speichersemantik) - der Aufrufer ist dafür + * verantwortlich, diese erst NACH `writeTo()` freizugeben. + */ + private suspend fun drawPhotoPage( + cursor: PdfPageCursor, + model: PdfExportModel, + photo: PdfExportPhoto, + index: Int, + paints: ExportPaints, + retainedPhotoBitmaps: MutableList, + ) { + cursor.forceNewPage() + if (index == 0) { + cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f) + } + cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f) + + val bitmap = runCatching { + photoStorage.decodeFitted( + documentationId = model.documentationId, + fileName = photo.fileName, + maxWidthPx = PHOTO_MAX_WIDTH_PX, + maxHeightPx = PHOTO_MAX_HEIGHT_PX, + ) + }.getOrNull() + + if (bitmap != null) { + retainedPhotoBitmaps.add(bitmap) + cursor.drawBitmapFitted(bitmap, paints.photo) + } else { + // Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B: + // neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar + // bleibt, dass an dieser Stelle ursprünglich ein Foto zur Dokumentation gehörte. + // Bewusst OHNE Dateipfad/technischen Fehlertext. + cursor.drawText("Foto konnte nicht geladen werden.", paints.item) + } + } } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt index d8cff06..000ab2f 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt @@ -48,6 +48,14 @@ object DocumentationToPdfExportModelMapper { birthDate = snapshot.birthDate, ) + // Explizit erneut nach orderIndex sortiert (nicht implizit auf die bereits im + // Domain-Modell vorhandene Reihenfolge verlassen) - dieselbe defensive Konsistenz wie + // bei blocks/items oben, gemäß Vorgabe "nicht Dateiname/UUID/createdAt/Dateisystem- + // Reihenfolge verwenden". + val photos = documentation.photos + .sortedBy { it.orderIndex } + .map { photo -> PdfExportPhoto(fileName = photo.fileName) } + return PdfExportModel( documentationId = documentation.id, fileName = fileName, @@ -56,6 +64,7 @@ object DocumentationToPdfExportModelMapper { patientDisplayAddress = snapshot.displayAddress, createdAt = createdAtDate, blocks = editedBlocks, + photos = photos, ) } } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt index 6079dec..8ee35ec 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt @@ -19,6 +19,8 @@ data class PdfExportModel( val createdAt: LocalDate, /** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */ val blocks: List, + /** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */ + val photos: List, ) data class PdfExportBlock( @@ -34,3 +36,12 @@ data class PdfExportItem( val isFreeText: Boolean, val freeTextValue: String, ) + +/** + * Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] - + * `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über + * [de.medapp.wunddoku.media.PhotoStorage] die intern gespeicherte Datei aufzulösen). + */ +data class PdfExportPhoto( + val fileName: String, +) diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt index e745a7f..23257e2 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt @@ -1,8 +1,10 @@ package de.medapp.wunddoku.pdf +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.RectF import android.graphics.pdf.PdfDocument import android.text.Layout import android.text.StaticLayout @@ -51,6 +53,14 @@ internal class PdfPageCursor( val contentWidth: Int get() = (pageWidth - marginLeft - marginRight).toInt() + /** Verbleibende Höhe auf der aktuellen Seite ab der aktuellen Cursor-Position (PDF-004: + * Grundlage für die Einpassung des Fotos nach einer bereits gezeichneten Bildunterschrift). */ + val remainingHeight: Float + get() { + startIfNeeded() + return (contentBottom() - cursorY).coerceAtLeast(0f) + } + private fun contentBottom(): Float = pageHeight - marginBottom private fun startIfNeeded() { @@ -77,6 +87,42 @@ internal class PdfPageCursor( canvas = null } + /** + * Erzwingt den Beginn einer neuen Seite, unabhängig davon, wie viel Platz auf der aktuellen + * Seite noch verfügbar wäre (PDF-004: jedes Foto beginnt zwingend auf einer eigenen neuen + * Seite, nie im verbleibenden Platz einer Dokumentations- oder vorherigen Fotoseite). Die + * Seitennummerierung läuft dabei fortlaufend weiter, da [startNewPage] intern lediglich + * `pageNumber` erhöht, nie zurücksetzt. + */ + fun forceNewPage() { + startNewPage() + } + + /** + * Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal + * über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der + * Seite. Setzt voraus, dass die Seite bereits begonnen wurde (i. d. R. direkt nach + * [forceNewPage] plus einer optionalen Bildunterschrift über [drawText]) - diese Methode + * selbst löst KEINEN Seitenumbruch aus, da ein Foto nicht über zwei Seiten verteilt werden + * darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest + * hineinskaliert (kleiner, nie abgeschnitten). + */ + fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) { + startIfNeeded() + val availableWidth = contentWidth.toFloat() + val availableHeight = remainingHeight + if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return + + val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) + val drawWidth = bitmap.width * scale + val drawHeight = bitmap.height * scale + val left = marginLeft + (availableWidth - drawWidth) / 2f + val top = cursorY + (availableHeight - drawHeight) / 2f + + canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint) + cursorY = contentBottom() + } + /** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal * aufgerufen werden. */ fun finish() { From c52229b7f7a3531a9aa020953f6f4f2561f70889 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Sun, 6 Sep 2026 21:19:58 +0200 Subject: [PATCH 4/4] PDF/MEDIA MEDIA-003 MEDIA-004 PDF-003 --- .../3.json | 585 ++++++++++++++++ .../4.json | 623 ++++++++++++++++++ .../medapp/wunddoku/data/dao/SignatureDao.kt | 9 +- .../medapp/wunddoku/data/dao/WoundCarerDao.kt | 33 + .../wunddoku/data/database/AppDatabase.kt | 35 +- .../wunddoku/data/database/Migrations.kt | 113 ++++ .../wunddoku/data/entity/SignatureEntity.kt | 16 +- .../wunddoku/data/entity/WoundCarerEntity.kt | 37 ++ .../data/mapper/DocumentationMapper.kt | 21 +- .../repository/DocumentationRepository.kt | 66 +- .../data/repository/WoundCarerRepository.kt | 82 +++ .../de/medapp/wunddoku/di/AppContainer.kt | 26 +- .../medapp/wunddoku/domain/model/Signature.kt | 13 +- .../wunddoku/domain/model/WoundCarer.kt | 16 + .../pdf/AndroidPdfDocumentExporter.kt | 137 ++++ .../DocumentationToPdfExportModelMapper.kt | 8 + .../de/medapp/wunddoku/pdf/PdfExportModel.kt | 15 + .../de/medapp/wunddoku/pdf/PdfPageCursor.kt | 70 ++ .../signature/SignatureBitmapValidation.kt | 29 + .../wunddoku/signature/SignatureStorage.kt | 132 ++++ .../edit/DocumentationEditScreen.kt | 171 +++++ .../edit/DocumentationEditViewModel.kt | 94 +++ .../edit/signature/AddSignatureDialog.kt | 235 +++++++ .../edit/signature/SignaturePad.kt | 156 +++++ app/src/main/res/values/strings.xml | 13 + 25 files changed, 2684 insertions(+), 51 deletions(-) create mode 100644 app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json create mode 100644 app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json new file mode 100644 index 0000000..15577b4 --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json @@ -0,0 +1,585 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "cd000019287c8c50d6b8d1b239aa12f0", + "entities": [ + { + "tableName": "patients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthDate", + "columnName": "birthDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "street", + "columnName": "street", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postalCode", + "columnName": "postalCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone3", + "columnName": "phone3", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_patients_lastName_firstName", + "unique": false, + "columnNames": [ + "lastName", + "firstName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)" + }, + { + "name": "index_patients_birthDate", + "unique": false, + "columnNames": [ + "birthDate" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "documentations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientId", + "columnName": "patientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "continuedFromDocumentationId", + "columnName": "continuedFromDocumentationId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pdfLastGeneratedAt", + "columnName": "pdfLastGeneratedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catalogVersion", + "columnName": "catalogVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientFirstNameSnapshot", + "columnName": "patientFirstNameSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientLastNameSnapshot", + "columnName": "patientLastNameSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientBirthDateSnapshot", + "columnName": "patientBirthDateSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientStreetSnapshot", + "columnName": "patientStreetSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientPostalCodeSnapshot", + "columnName": "patientPostalCodeSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientCitySnapshot", + "columnName": "patientCitySnapshot", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_documentations_patientId_createdAt", + "unique": false, + "columnNames": [ + "patientId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)" + }, + { + "name": "index_documentations_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_documentations_continuedFromDocumentationId", + "unique": false, + "columnNames": [ + "continuedFromDocumentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)" + } + ], + "foreignKeys": [ + { + "table": "patients", + "onDelete": "RESTRICT", + "onUpdate": "NO ACTION", + "columns": [ + "patientId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "documentations", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "continuedFromDocumentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "block_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockDefinitionId", + "columnName": "blockDefinitionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "titleSnapshot", + "columnName": "titleSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockOrder", + "columnName": "blockOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_block_states_documentationId", + "unique": false, + "columnNames": [ + "documentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)" + }, + { + "name": "index_block_states_documentationId_blockDefinitionId", + "unique": true, + "columnNames": [ + "documentationId", + "blockDefinitionId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "check_item_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockStateId", + "columnName": "blockStateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checkItemDefinitionId", + "columnName": "checkItemDefinitionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelSnapshot", + "columnName": "labelSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemOrder", + "columnName": "itemOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checked", + "columnName": "checked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFreeText", + "columnName": "isFreeText", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeTextValue", + "columnName": "freeTextValue", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_check_item_states_blockStateId", + "unique": false, + "columnNames": [ + "blockStateId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)" + }, + { + "name": "index_check_item_states_blockStateId_checkItemDefinitionId", + "unique": true, + "columnNames": [ + "blockStateId", + "checkItemDefinitionId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)" + } + ], + "foreignKeys": [ + { + "table": "block_states", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "blockStateId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "photos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_photos_documentationId_orderIndex", + "unique": false, + "columnNames": [ + "documentationId", + "orderIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "signatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerName", + "columnName": "signerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_signatures_documentationId", + "unique": false, + "columnNames": [ + "documentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd000019287c8c50d6b8d1b239aa12f0')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json new file mode 100644 index 0000000..c28d05f --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json @@ -0,0 +1,623 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "182fcff50c0191c9414187dd7b7cacd2", + "entities": [ + { + "tableName": "patients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthDate", + "columnName": "birthDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "street", + "columnName": "street", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postalCode", + "columnName": "postalCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone3", + "columnName": "phone3", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_patients_lastName_firstName", + "unique": false, + "columnNames": [ + "lastName", + "firstName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)" + }, + { + "name": "index_patients_birthDate", + "unique": false, + "columnNames": [ + "birthDate" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "documentations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientId", + "columnName": "patientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "continuedFromDocumentationId", + "columnName": "continuedFromDocumentationId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pdfLastGeneratedAt", + "columnName": "pdfLastGeneratedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catalogVersion", + "columnName": "catalogVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientFirstNameSnapshot", + "columnName": "patientFirstNameSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientLastNameSnapshot", + "columnName": "patientLastNameSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientBirthDateSnapshot", + "columnName": "patientBirthDateSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientStreetSnapshot", + "columnName": "patientStreetSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientPostalCodeSnapshot", + "columnName": "patientPostalCodeSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientCitySnapshot", + "columnName": "patientCitySnapshot", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_documentations_patientId_createdAt", + "unique": false, + "columnNames": [ + "patientId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)" + }, + { + "name": "index_documentations_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_documentations_continuedFromDocumentationId", + "unique": false, + "columnNames": [ + "continuedFromDocumentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)" + } + ], + "foreignKeys": [ + { + "table": "patients", + "onDelete": "RESTRICT", + "onUpdate": "NO ACTION", + "columns": [ + "patientId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "documentations", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "continuedFromDocumentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "block_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockDefinitionId", + "columnName": "blockDefinitionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "titleSnapshot", + "columnName": "titleSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockOrder", + "columnName": "blockOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_block_states_documentationId", + "unique": false, + "columnNames": [ + "documentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)" + }, + { + "name": "index_block_states_documentationId_blockDefinitionId", + "unique": true, + "columnNames": [ + "documentationId", + "blockDefinitionId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "check_item_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockStateId", + "columnName": "blockStateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checkItemDefinitionId", + "columnName": "checkItemDefinitionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelSnapshot", + "columnName": "labelSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemOrder", + "columnName": "itemOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checked", + "columnName": "checked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFreeText", + "columnName": "isFreeText", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeTextValue", + "columnName": "freeTextValue", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_check_item_states_blockStateId", + "unique": false, + "columnNames": [ + "blockStateId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)" + }, + { + "name": "index_check_item_states_blockStateId_checkItemDefinitionId", + "unique": true, + "columnNames": [ + "blockStateId", + "checkItemDefinitionId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)" + } + ], + "foreignKeys": [ + { + "table": "block_states", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "blockStateId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "photos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_photos_documentationId_orderIndex", + "unique": false, + "columnNames": [ + "documentationId", + "orderIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "signatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerName", + "columnName": "signerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_signatures_documentationId", + "unique": false, + "columnNames": [ + "documentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "wound_carers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureFileName", + "columnName": "signatureFileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '182fcff50c0191c9414187dd7b7cacd2')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt index 7d5a682..60f973d 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt @@ -18,6 +18,13 @@ interface SignatureDao { @Query("SELECT * FROM signatures WHERE id = :id") suspend fun getById(id: String): SignatureEntity? - @Query("SELECT * FROM signatures WHERE documentationId = :documentationId") + /** Bereits stabil nach [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex] sortiert + * - dieselbe Reihenfolge, in der Signaturen später auch im PDF erscheinen (PDF-003). */ + @Query("SELECT * FROM signatures WHERE documentationId = :documentationId ORDER BY orderIndex") suspend fun getForDocumentation(documentationId: String): List + + /** Für die Vergabe des nächsten [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex] + * beim Anlegen einer neuen Signatur - exakt analog [PhotoDao.getMaxOrderIndex]. */ + @Query("SELECT COALESCE(MAX(orderIndex), -1) FROM signatures WHERE documentationId = :documentationId") + suspend fun getMaxOrderIndex(documentationId: String): Int } diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt new file mode 100644 index 0000000..6766c2f --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt @@ -0,0 +1,33 @@ +package de.medapp.wunddoku.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import de.medapp.wunddoku.data.entity.WoundCarerEntity + +@Dao +interface WoundCarerDao { + + /** Ersetzt einen vorhandenen Eintrag mit derselben [WoundCarerEntity.id] vollständig (Upsert + * per Primärschlüssel) - siehe [de.medapp.wunddoku.data.repository.WoundCarerRepository] + * (Find-by-Name + Wiederverwendung der gefundenen ID) für die eigentliche + * "Neuanlegen-oder-Aktualisieren"-Logik anhand des Namens. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdate(entity: WoundCarerEntity) + + /** Case-insensitiver Exakt-Abgleich - entscheidet, ob unter diesem (getrimmten) Namen + * bereits ein Wundversorger bekannt ist (dann: Signatur aktualisieren) oder ein neuer Eintrag + * angelegt werden muss. */ + @Query("SELECT * FROM wound_carers WHERE name = :name COLLATE NOCASE LIMIT 1") + suspend fun findByName(name: String): WoundCarerEntity? + + /** Namensvorschläge nach den ersten eingegebenen Buchstaben (Präfix, case-insensitiv), + * neueste Verwendung zuerst. */ + @Query("SELECT * FROM wound_carers WHERE name LIKE :prefix || '%' COLLATE NOCASE ORDER BY updatedAt DESC LIMIT :limit") + suspend fun searchByNamePrefix(prefix: String, limit: Int): List + + /** Für die Vorbelegung des Signatur-Dialogs mit dem zuletzt verwendeten Wundversorger. */ + @Query("SELECT * FROM wound_carers ORDER BY updatedAt DESC LIMIT 1") + suspend fun getMostRecentlyUsed(): WoundCarerEntity? +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt index a196638..5c1cec3 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt @@ -11,12 +11,14 @@ import de.medapp.wunddoku.data.dao.DocumentationDao import de.medapp.wunddoku.data.dao.PatientDao import de.medapp.wunddoku.data.dao.PhotoDao import de.medapp.wunddoku.data.dao.SignatureDao +import de.medapp.wunddoku.data.dao.WoundCarerDao import de.medapp.wunddoku.data.entity.BlockStateEntity import de.medapp.wunddoku.data.entity.CheckItemStateEntity import de.medapp.wunddoku.data.entity.DocumentationEntity import de.medapp.wunddoku.data.entity.PatientEntity import de.medapp.wunddoku.data.entity.PhotoEntity import de.medapp.wunddoku.data.entity.SignatureEntity +import de.medapp.wunddoku.data.entity.WoundCarerEntity import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter /** @@ -26,18 +28,22 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter * Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung - * Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von * CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf - * DocumentationEntity. + * DocumentationEntity. Dieser Sprung nutzte noch `fallbackToDestructiveMigration()` (die App + * befand sich vor dem produktiven Einsatz, keine echten Patientendaten zu migrieren). * - * `fallbackToDestructiveMigration()`: Da die App sich noch vor dem produktiven Einsatz befindet - * und keine echten Patientendaten migriert werden müssen, wird hier bewusst auf eine - * geschriebene `Migration`-Klasse verzichtet - die lokale Datenbank wird bei einem - * Versionssprung einfach neu angelegt (bestehende lokale Testdaten auf dem Gerät/Emulator gehen - * dabei verloren). Das ist eine bewusste, vorübergehende Entwicklungsentscheidung: VOR dem - * ersten produktiven Rollout muss dies durch eine echte `Migration(1, 2) { ... }` ersetzt - * werden, die reale Patientendaten erhält (insbesondere: die bisherige `address`-Spalte müsste - * dann sinnvoll in street/postalCode/city überführt werden, z. B. gesamten Altwert nach - * `street` und postalCode/city leer lassen, da eine automatische Text-Zerlegung einer - * Freitext-Adresse nicht zuverlässig möglich ist). + * Version 3 (vorher 2, MEDIA-004/PDF-003): `signatures.orderIndex` neu (stabile Reihenfolge + * mehrerer Signaturen je Dokumentation) sowie `signatures.signerName` von NULLABLE auf NOT NULL. + * Anders als beim Sprung 1->2 wird HIER eine echte, verlustfreie [Migration] verwendet + * ([MIGRATION_2_3]) - reale Installationen können ab jetzt bereits Patienten-/ + * Dokumentationsdaten enthalten, die nicht stillschweigend durch einen destruktiven Fallback + * verloren gehen dürfen (Aufgabenstellung Abschnitt 23). Der bisherige + * `fallbackToDestructiveMigration()`-Aufruf wurde deshalb entfernt; fehlt für einen künftigen + * Versionssprung eine passende Migration, bricht Room den Aufbau der Datenbank kontrolliert mit + * einer Exception ab, statt Daten unbemerkt zu löschen. + * + * Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers` + * (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein + * additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen. */ @Database( entities = [ @@ -47,8 +53,9 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter CheckItemStateEntity::class, PhotoEntity::class, SignatureEntity::class, + WoundCarerEntity::class, ], - version = 2, + version = 4, exportSchema = true, ) @TypeConverters(DocumentationStatusConverter::class) @@ -60,6 +67,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun checkItemStateDao(): CheckItemStateDao abstract fun photoDao(): PhotoDao abstract fun signatureDao(): SignatureDao + abstract fun woundCarerDao(): WoundCarerDao companion object { private const val DATABASE_NAME = "wunddoku.db" @@ -74,7 +82,8 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, DATABASE_NAME, ) - .fallbackToDestructiveMigration() // s. Klassenkommentar: vorübergehend, vor Produktivbetrieb ersetzen + // Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar. + .addMigrations(MIGRATION_2_3, MIGRATION_3_4) .build().also { instance = it } } } diff --git a/app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt b/app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt new file mode 100644 index 0000000..31f5ec1 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt @@ -0,0 +1,113 @@ +package de.medapp.wunddoku.data.database + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Migration von Schema-Version 2 auf 3 (MEDIA-004/PDF-003: Mehrere Signaturen pro + * Dokumentation). + * + * Zwei Änderungen am `signatures`-Schema, die NICHT über ein einfaches `ALTER TABLE ... ADD + * COLUMN` abgebildet werden können: + * + * 1. Neue Spalte `orderIndex INTEGER NOT NULL` für eine stabile Reihenfolge mehrerer + * Signaturen (bisher gab es nur eine implizite, undefinierte Reihenfolge). + * 2. `signerName` wechselt von NULLABLE auf NOT NULL. SQLite kann eine bestehende Spalte nicht + * nachträglich auf NOT NULL umstellen - deshalb wird die Tabelle komplett neu aufgebaut + * (neue Tabelle mit Zielschema anlegen, Daten verlustfrei übernehmen, alte Tabelle + * entfernen, neue Tabelle umbenennen, Index wiederherstellen). + * + * Es wird NICHT angenommen, dass die Tabelle leer ist, nur weil die Signaturfunktion vor + * dieser Version über keine UI erreichbar war (Aufgabenstellung Abschnitt 22) - die Migration + * behandelt den allgemeinen Fall mit ggf. bereits vorhandenen Zeilen: + * + * - `orderIndex` wird pro `documentationId` anhand von `createdAt` (mit `id` als + * Tie-Breaker für den unwahrscheinlichen Fall exakt gleicher Zeitstempel) rückwirkend + * vergeben, beginnend bei 0 - über eine korrelierte Unterabfrage, da SQLite in der von Room + * unterstützten Form kein `ROW_NUMBER()`-Fenster in einem `UPDATE` bereitstellt. + * - `signerName` wird für Altzeilen mit NULL **oder** leerem/nur-Leerzeichen-Wert auf den klar + * als historisch fehlend erkennbaren technischen Platzhalter `"(nicht erfasst)"` gesetzt + * (ausdrücklich freigegeben - NICHT `""`, NICHT `"Unbekannt"`, da Letzteres wie ein echter + * Name wirken könnte). Der leere-String-Fall wurde defensiv ergänzt: `signerName` war in + * Schema 2 zwar nullable, aber nie auf "nicht blank" geprüft - ein rein theoretisch über + * einen früheren Code-Pfad denkbarer Leerstring soll denselben Platzhalter erhalten wie + * NULL, damit hier keine Zeile mit einem leeren, aber technisch "vorhandenen" Namen entsteht. + * Diese Regel gilt ausschließlich für migrierte Altbestände - neu erfasste Signaturen + * erzwingen weiterhin serverseitig (Repository) ein nicht-leeres, getrimmtes Pflichtfeld. + * + * Da die Signaturfunktion vor dieser Version über keine UI erreichbar war, ist in der Praxis auf + * so gut wie jeder realen Installation ohnehin mit einer leeren `signatures`-Tabelle zu rechnen + * - die obige Logik greift dann einfach nicht (INSERT ... SELECT liefert 0 Zeilen). + */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `signatures_new` ( + `id` TEXT NOT NULL, + `documentationId` TEXT NOT NULL, + `signerName` TEXT NOT NULL, + `fileName` TEXT NOT NULL, + `orderIndex` INTEGER NOT NULL, + `createdAt` INTEGER NOT NULL, + PRIMARY KEY(`id`), + FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO `signatures_new` (`id`, `documentationId`, `signerName`, `fileName`, `orderIndex`, `createdAt`) + SELECT + s.id, + s.documentationId, + CASE + WHEN s.signerName IS NULL OR TRIM(s.signerName) = '' THEN '(nicht erfasst)' + ELSE s.signerName + END, + s.fileName, + ( + SELECT COUNT(*) + FROM signatures s2 + WHERE s2.documentationId = s.documentationId + AND ( + s2.createdAt < s.createdAt + OR (s2.createdAt = s.createdAt AND s2.id < s.id) + ) + ), + s.createdAt + FROM signatures s + """.trimIndent() + ) + + db.execSQL("DROP TABLE `signatures`") + db.execSQL("ALTER TABLE `signatures_new` RENAME TO `signatures`") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `signatures` (`documentationId`)") + } +} + +/** + * Migration von Schema-Version 3 auf 4 (Änderungswunsch: Wundversorger-Register für + * Namensvorschläge und Standard-Vorbelegung im Signatur-Dialog). + * + * Rein additiv: eine neue, komplett eigenständige Tabelle `wound_carers` ohne Fremdschlüssel- + * Bezug zu einer bestehenden Tabelle (unabhängig vom Lebenszyklus einzelner Dokumentationen/ + * Signaturen, s. [de.medapp.wunddoku.data.repository.WoundCarerRepository]). Keine bestehenden + * Tabellen/Daten werden hierbei verändert. + */ +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `wound_carers` ( + `id` TEXT NOT NULL, + `name` TEXT NOT NULL, + `signatureFileName` TEXT NOT NULL, + `updatedAt` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt index 9520bae..9f3148e 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt @@ -7,9 +7,14 @@ import androidx.room.PrimaryKey /** * Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an - * DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen - * (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter - * context.filesDir/signatures/ – kein BLOB in SQLite. + * DocumentationEntity, damit eine Dokumentation beliebig viele Unterschriften unterschiedlicher + * Wundversorger besitzen kann (1:n, MEDIA-004). [fileName] verweist auf eine PNG-Datei unter + * context.filesDir/signatures// – kein BLOB in SQLite. + * + * Schema-Historie: In Version 2 war [signerName] noch nullable und es gab kein [orderIndex] - + * Migration 2->3 ([de.medapp.wunddoku.data.database.MIGRATION_2_3]) baut die Tabelle deshalb neu + * auf (SQLite kann eine Spalte nicht per ALTER TABLE von NULL auf NOT NULL umstellen) und füllt + * dabei sowohl [orderIndex] als auch fehlende [signerName]-Werte auf. */ @Entity( tableName = "signatures", @@ -28,7 +33,10 @@ import androidx.room.PrimaryKey data class SignatureEntity( @PrimaryKey val id: String, val documentationId: String, - val signerName: String?, + /** Fachliches Pflichtfeld seit Schema 3 (non-null, getrimmt, nicht blank) - s. Repository. */ + val signerName: String, val fileName: String, + /** Stabile Reihenfolge mehrerer Signaturen einer Dokumentation, analog [de.medapp.wunddoku.data.entity.PhotoEntity.orderIndex]. */ + val orderIndex: Int, val createdAt: Long, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt new file mode 100644 index 0000000..c5c49fd --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt @@ -0,0 +1,37 @@ +package de.medapp.wunddoku.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger-Namen samt ihrer jeweils + * AKTUELLSTEN Signatur - AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: es gibt keine + * Anmeldung, keine Rechte, keine Verknüpfung zu historischen Dokumentationen, und (vorerst) keine + * eigene Verwaltungsansicht zum Bearbeiten/Löschen einzelner Einträge - ausschließlich internes + * Cache für zwei Komfortfunktionen im Signatur-Dialog: + * + * 1. Namensvorschläge nach den ersten eingegebenen Buchstaben. + * 2. Vorbelegung von Name UND Signatur mit dem zuletzt verwendeten Wundversorger beim Öffnen des + * Dialogs (weiterhin änderbar). + * + * WICHTIG: Diese Tabelle hat KEINEN Einfluss auf bereits gespeicherte [SignatureEntity]-Zeilen - + * jede historische Signatur bleibt ein unveränderlicher Snapshot (Name + Bild) zum Zeitpunkt + * ihrer Erstellung. [signatureFileName] hier wird bei jeder neu gezeichneten Signatur unter + * demselben Namen überschrieben ("die letzte geänderte Version wird gespeichert") - komplett + * unabhängig vom Speicherort/Lebenszyklus der pro Dokumentation gespeicherten Signaturen. + */ +@Entity(tableName = "wound_carers") +data class WoundCarerEntity( + @PrimaryKey val id: String, + /** Trimmter Anzeigename, wie zuletzt eingegeben. Abgleich beim Wiedererkennen erfolgt + * case-insensitive (s. WoundCarerDao), gespeichert wird die zuletzt getippte Schreibweise. */ + val name: String, + /** Relative Referenz auf die aktuellste PNG-Signatur dieses Wundversorgers, verwaltet über + * eine eigene, von [de.medapp.wunddoku.signature.SignatureStorage]-Instanz mit eigenem + * Wurzelverzeichnis (unabhängig von jeder documentationId). */ + val signatureFileName: String, + /** Zeitpunkt der letzten Verwendung/Aktualisierung - bestimmt sowohl die Sortierung der + * Namensvorschläge als auch, wer als Standard beim Öffnen des Dialogs vorbelegt wird + * (jeweils der zuletzt aktualisierte Eintrag). */ + val updatedAt: Long, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt index 520bc14..e3ef34e 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt @@ -40,15 +40,18 @@ fun DocumentationWithDetails.toDomain(): Documentation = Documentation( createdAt = Instant.ofEpochMilli(it.createdAt), ) }, - signatures = signatures.map { - de.medapp.wunddoku.domain.model.Signature( - id = it.id, - documentationId = it.documentationId, - signerName = it.signerName, - fileName = it.fileName, - createdAt = Instant.ofEpochMilli(it.createdAt), - ) - }, + signatures = signatures + .sortedBy { it.orderIndex } + .map { + de.medapp.wunddoku.domain.model.Signature( + id = it.id, + documentationId = it.documentationId, + signerName = it.signerName, + fileName = it.fileName, + orderIndex = it.orderIndex, + createdAt = Instant.ofEpochMilli(it.createdAt), + ) + }, ) fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock( 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 7f4446a..c12f160 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 @@ -29,7 +29,8 @@ 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.SignatureBitmapValidation +import de.medapp.wunddoku.signature.SignatureStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.time.Instant @@ -56,7 +57,8 @@ class DocumentationRepository( private val signatureDao: SignatureDao, private val patientDao: PatientDao, private val photoStorage: PhotoStorage, - private val signatureFileStorage: SignatureFileStorage, + private val signatureStorage: SignatureStorage, + private val woundCarerRepository: WoundCarerRepository, private val pdfExporter: DocumentationPdfExporter, private val db: AppDatabase, ) { @@ -290,37 +292,73 @@ class DocumentationRepository( suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) - suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { - val fileName = signatureFileStorage.save(bitmap) + /** + * Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates + * Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein + * genug für die direkte Anzeige. Liefert `null` bei fehlender/beschädigter Datei (kein + * Absturz, konsistent zu [loadPhotoThumbnail]). + */ + suspend fun loadSignatureBitmap(documentationId: String, fileName: String): Bitmap? = + signatureStorage.decode(documentationId, fileName) + + /** + * Speichert eine neue Signatur (MEDIA-003/004). [signerName] ist fachliches Pflichtfeld: + * wird getrimmt, ein danach leerer String wird abgelehnt (Aufgabenstellung Abschnitt 8) - + * ebenso eine leere Zeichnung ([SignatureBitmapValidation.isBlank], Abschnitt 12: zweite, + * von der UI unabhängige Absicherungsebene). [orderIndex] wird analog [addPhoto] fortlaufend + * je Dokumentation vergeben, damit mehrere Signaturen eine stabile Reihenfolge haben + * (Anzeige UND spätere PDF-Reihenfolge, PDF-003). Die Namensvalidierung erfolgt VOR dem + * Schreiben der PNG-Datei, damit bei einem ungültigen Namen keine verwaiste Datei entsteht. + * + * Aktualisiert nach erfolgreichem Speichern zusätzlich das Wundversorger-Register + * ([WoundCarerRepository.recordSignatureUsage]) mit derselben Signatur - unabhängig davon, + * ob [bitmap] frisch gezeichnet oder aus einem Namensvorschlag im Dialog übernommen wurde. + * Das hat KEINEN Einfluss auf den hier gerade erzeugten, unveränderlichen [Signature]- + * Snapshot dieser Dokumentation. + */ + suspend fun saveSignature(documentationId: String, signerName: String, bitmap: Bitmap): Signature { + val trimmedName = signerName.trim() + require(trimmedName.isNotEmpty()) { "Der Name des Wundversorgers darf nicht leer sein." } + require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." } + + val fileName = signatureStorage.save(documentationId, bitmap) + val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1 val now = Instant.now() val entity = SignatureEntity( id = UUID.randomUUID().toString(), documentationId = documentationId, - signerName = signerName, + signerName = trimmedName, fileName = fileName, + orderIndex = nextOrderIndex, createdAt = now.toEpochMilli(), ) signatureDao.insert(entity) - return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now) + woundCarerRepository.recordSignatureUsage(trimmedName, bitmap) + return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now) } - /** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */ + /** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. Signaturen sind nach dem + * Speichern unveränderlich (Aufgabenstellung Abschnitt 17) - "korrigieren" bedeutet fachlich + * immer löschen + neu erfassen, nie ein nachträgliches Bearbeiten. */ suspend fun removeSignature(signatureId: String) { val signature = signatureDao.getById(signatureId) ?: return signatureDao.delete(signature) - runCatching { signatureFileStorage.delete(signature.fileName) } + runCatching { signatureStorage.delete(signature.documentationId, signature.fileName) } } suspend fun delete(documentationId: String) { val entity = documentationDao.getById(documentationId) ?: return - val signatures = signatureDao.getForDocumentation(documentationId) documentationDao.delete(entity) - // Ein einziger Verzeichnis-Löschvorgang statt Einzeldateien - siehe PhotoStorage- - // Kommentar zur dokumentationsbezogenen Ordnerstruktur. Room-Cascade entfernt zwar die - // PhotoEntity-Zeilen automatisch, aber keine Dateien aus filesDir - das übernimmt - // ausschließlich diese Methode. + // Je ein einziger Verzeichnis-Löschvorgang statt Einzeldateien für Fotos UND Signaturen + // - beide Storage-Implementierungen organisieren ihre Dateien pro documentationId in + // einem eigenen Unterordner (s. PhotoStorage-/SignatureStorage-Kommentar). Room-Cascade + // entfernt zwar die PhotoEntity-/SignatureEntity-Zeilen automatisch, aber keine Dateien + // aus filesDir - das übernimmt ausschließlich diese Methode. Da deleteAll() über den + // Ordnernamen (documentationId) statt über zuvor geladene DB-Zeilen arbeitet, muss hier + // - anders als vorher bei den Signaturen - auch keine Liste mehr vor dem DB-Delete + // geladen werden. runCatching { photoStorage.deleteAll(documentationId) } - signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } + runCatching { signatureStorage.deleteAll(documentationId) } } /** diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt new file mode 100644 index 0000000..dd619b2 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt @@ -0,0 +1,82 @@ +package de.medapp.wunddoku.data.repository + +import android.graphics.Bitmap +import de.medapp.wunddoku.data.dao.WoundCarerDao +import de.medapp.wunddoku.data.entity.WoundCarerEntity +import de.medapp.wunddoku.domain.model.WoundCarer +import de.medapp.wunddoku.signature.SignatureStorage +import java.time.Instant +import java.util.UUID + +/** + * Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger (Namensvorschläge + + * Vorbelegung im Signatur-Dialog mit Name UND Signatur des zuletzt verwendeten Wundversorgers). + * + * AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: keine Anmeldung, keine Rechte, keine + * Verknüpfung zu [DocumentationRepository]-Signaturen, und (vorerst) keine eigene + * Verwaltungsansicht. Ändert insbesondere NIE bereits gespeicherte, historische + * [de.medapp.wunddoku.domain.model.Signature]-Snapshots einzelner Dokumentationen - diese bleiben + * unveränderlich, wie ursprünglich festgelegt. Diese Klasse verwaltet eine komplett separate + * Tabelle (`wound_carers`) und ein separates Dateiverzeichnis + * (`filesDir/wound_carer_signatures/`, s. [de.medapp.wunddoku.signature.InternalSignatureStorage]). + */ +class WoundCarerRepository( + private val woundCarerDao: WoundCarerDao, + private val woundCarerSignatureStorage: SignatureStorage, +) { + + /** Für die Vorbelegung des Signatur-Dialogs beim Öffnen: der zuletzt verwendete + * Wundversorger (app-weit, nicht auf eine einzelne Dokumentation beschränkt) - `null`, wenn + * noch nie eine Signatur gespeichert wurde. */ + suspend fun getMostRecentlyUsed(): WoundCarer? = woundCarerDao.getMostRecentlyUsed()?.toDomain() + + /** Namensvorschläge nach den ersten eingegebenen Buchstaben. Leerer/blank [prefix] liefert + * bewusst keine Vorschläge (kein "alle anzeigen" bei leerem Feld). */ + suspend fun search(prefix: String, limit: Int = 5): List { + val trimmed = prefix.trim() + if (trimmed.isEmpty()) return emptyList() + return woundCarerDao.searchByNamePrefix(trimmed, limit).map { it.toDomain() } + } + + /** Lädt die aktuell hinterlegte Signatur eines bekannten Wundversorgers, z. B. zur Anzeige + * einer Vorschau im Signatur-Dialog nach Auswahl eines Namensvorschlags. */ + suspend fun loadSignatureBitmap(woundCarerId: String, fileName: String): Bitmap? = + woundCarerSignatureStorage.decode(woundCarerId, fileName) + + /** + * Wird bei JEDER erfolgreich gespeicherten Signatur aufgerufen (s. + * [DocumentationRepository.saveSignature]) und aktualisiert das Register: existiert unter + * [name] (getrimmt, case-insensitiver Abgleich) bereits ein Eintrag, wird dessen Signatur + * durch [bitmap] ersetzt (alte Datei wird gelöscht) und der Zeitstempel aktualisiert - + * "die letzte geänderte Version der Unterschrift eines Wundversorgers wird gespeichert". + * Andernfalls wird ein neuer Eintrag angelegt. Verwendet dieselbe [bitmap]-Instanz, die auch + * als historischer Snapshot der jeweiligen Dokumentation gespeichert wird - es wird lediglich + * eine ZWEITE, unabhängige PNG-Kopie in einem eigenen Verzeichnis abgelegt (kein Teilen + * derselben Datei), damit ein späteres Löschen der Dokumentation dieses Register niemals + * beeinträchtigen kann. + */ + suspend fun recordSignatureUsage(name: String, bitmap: Bitmap) { + val existing = woundCarerDao.findByName(name) + val id = existing?.id ?: UUID.randomUUID().toString() + val newFileName = woundCarerSignatureStorage.save(id, bitmap) + woundCarerDao.insertOrUpdate( + WoundCarerEntity( + id = id, + name = name, + signatureFileName = newFileName, + updatedAt = System.currentTimeMillis(), + ) + ) + val oldFileName = existing?.signatureFileName + if (oldFileName != null && oldFileName != newFileName) { + runCatching { woundCarerSignatureStorage.delete(id, oldFileName) } + } + } + + private fun WoundCarerEntity.toDomain() = WoundCarer( + id = id, + name = name, + signatureFileName = signatureFileName, + updatedAt = Instant.ofEpochMilli(updatedAt), + ) +} 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 ab99822..ab5bbaf 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -4,12 +4,13 @@ import android.content.Context import de.medapp.wunddoku.data.database.AppDatabase import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.PatientRepository +import de.medapp.wunddoku.data.repository.WoundCarerRepository import de.medapp.wunddoku.media.InternalPhotoStorage import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter -import de.medapp.wunddoku.signature.InternalSignatureFileStorage -import de.medapp.wunddoku.signature.SignatureFileStorage +import de.medapp.wunddoku.signature.InternalSignatureStorage +import de.medapp.wunddoku.signature.SignatureStorage /** * Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories, @@ -28,13 +29,27 @@ class AppContainer(context: Context) { private val database: AppDatabase by lazy { AppDatabase.getInstance(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, photoStorage) } + private val signatureStorage: SignatureStorage by lazy { InternalSignatureStorage(context) } + /** Eigenes Wurzelverzeichnis, komplett unabhängig von den dokumentationsbezogenen + * Signaturen - s. [InternalSignatureStorage]-Klassenkommentar. */ + private val woundCarerSignatureStorage: SignatureStorage by lazy { + InternalSignatureStorage(context, rootDirName = "wound_carer_signatures") + } + private val pdfExporter: DocumentationPdfExporter by lazy { + AndroidPdfDocumentExporter(context, photoStorage, signatureStorage) + } val patientRepository: PatientRepository by lazy { PatientRepository(patientDao = database.patientDao()) } + val woundCarerRepository: WoundCarerRepository by lazy { + WoundCarerRepository( + woundCarerDao = database.woundCarerDao(), + woundCarerSignatureStorage = woundCarerSignatureStorage, + ) + } + val documentationRepository: DocumentationRepository by lazy { DocumentationRepository( documentationDao = database.documentationDao(), @@ -44,7 +59,8 @@ class AppContainer(context: Context) { signatureDao = database.signatureDao(), patientDao = database.patientDao(), photoStorage = photoStorage, - signatureFileStorage = signatureFileStorage, + signatureStorage = signatureStorage, + woundCarerRepository = woundCarerRepository, pdfExporter = pdfExporter, db = database, ) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt index b825574..8ffbcb1 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt @@ -5,15 +5,18 @@ import java.time.Instant /** * Eine digitale Unterschrift zu einer Wunddokumentation. * - * Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später - * mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können - * (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen - * App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11. + * Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit beliebig + * viele Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können + * (1:n-Beziehung, MEDIA-004). [fileName] verweist auf eine PNG-Datei im internen App-Speicher + * (kein BLOB in SQLite). [signerName] ist ein historischer Snapshot: spätere Änderungen an + * eventuellen Wundversorger-Stammdaten dürfen bereits gespeicherte Signaturen nicht rückwirkend + * verändern. [orderIndex] bestimmt die stabile Anzeige-/Export-Reihenfolge mehrerer Signaturen. */ data class Signature( val id: String, val documentationId: String, - val signerName: String?, + val signerName: String, val fileName: String, + val orderIndex: Int, val createdAt: Instant, ) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt new file mode 100644 index 0000000..b582680 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt @@ -0,0 +1,16 @@ +package de.medapp.wunddoku.domain.model + +import java.time.Instant + +/** + * Ein zuletzt verwendeter Wundversorger samt seiner aktuellsten Signatur - reines Komfort-Cache + * für Namensvorschläge und Vorbelegung im Signatur-Dialog, KEIN Benutzer-/Account-Objekt (s. + * [de.medapp.wunddoku.data.entity.WoundCarerEntity]). Hat keinerlei Einfluss auf bereits + * gespeicherte, historische [Signature]-Snapshots einzelner Dokumentationen. + */ +data class WoundCarer( + val id: String, + val name: String, + val signatureFileName: String, + val updatedAt: Instant, +) diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt index 9812652..64f7b79 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -8,6 +8,7 @@ import android.graphics.Paint import android.graphics.pdf.PdfDocument import android.text.TextPaint import de.medapp.wunddoku.media.PhotoStorage +import de.medapp.wunddoku.signature.SignatureStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -47,6 +48,7 @@ import java.time.format.FormatStyle class AndroidPdfDocumentExporter( private val context: Context, private val photoStorage: PhotoStorage, + private val signatureStorage: SignatureStorage, ) : DocumentationPdfExporter { companion object { @@ -63,6 +65,25 @@ class AndroidPdfDocumentExporter( private const val FREE_TEXT_TEXT_SIZE = 10.5f private const val FREE_TEXT_INDENT = 20f + // PDF-003: Signaturbereich. Fachlich handschriftliche Unterschriften, keine + // kryptographische PDF-Signatur (s. Aufgabenstellung Abschnitt 5). + private const val SIGNATURE_LABEL_TEXT_SIZE = 11f + private const val SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE = 11f + private const val SIGNATURE_PLACEHOLDER_TEXT_SIZE = 10.5f + + /** Feste Zielbreite des Unterschriftsbilds im PDF (deutlich schmaler als die volle + * Inhaltsbreite - eine Unterschrift muss nicht seitenbreit dargestellt werden). Bei dem + * logischen 1000x400-Signaturformat (Seitenverhältnis 2,5:1, s. Zeichenfläche) ergibt + * das eine Bildhöhe von rund 88pt. */ + private const val SIGNATURE_IMAGE_TARGET_WIDTH_PT = 220f + private const val SIGNATURE_LINE_WIDTH_PT = 220f + + private const val SIGNATURE_SPACING_AFTER_LABEL = 6f + private const val SIGNATURE_SPACING_AFTER_IMAGE = 4f + private const val SIGNATURE_SPACING_AFTER_LINE = 2f + private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f + private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f + // 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 @@ -116,6 +137,10 @@ class AndroidPdfDocumentExporter( // Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig // bleiben und werden deshalb erst im finally-Block unten recycelt. val retainedPhotoBitmaps = mutableListOf() + // Signatur-Bitmaps unterliegen exakt derselben PdfDocument-Speichersemantik wie + // Foto-Bitmaps (s. Klassendokumentation) - eigene Liste statt Wiederverwendung von + // [retainedPhotoBitmaps], damit beide Kategorien unabhängig recycelt werden können. + val retainedSignatureBitmaps = mutableListOf() try { val paints = ExportPaints() val cursor = PdfPageCursor( @@ -132,6 +157,10 @@ class AndroidPdfDocumentExporter( for (block in model.blocks) { drawBlock(cursor, block, paints) } + // Reihenfolge PDF-003/Abschnitt 25: Dokumentationsblöcke -> Signaturen -> Fotoanhang. + if (model.signatures.isNotEmpty()) { + drawSignatures(cursor, model, paints, retainedSignatureBitmaps) + } for ((index, photo) in model.photos.withIndex()) { drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps) } @@ -159,6 +188,7 @@ class AndroidPdfDocumentExporter( // bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist - // in beiden Fällen werden diese Bitmaps nicht mehr gebraucht. retainedPhotoBitmaps.forEach { runCatching { it.recycle() } } + retainedSignatureBitmaps.forEach { runCatching { it.recycle() } } } } @@ -197,6 +227,23 @@ class AndroidPdfDocumentExporter( color = Color.DKGRAY } + // PDF-003: Signaturbereich. + val signatureLabel = TextPaint().apply { + isAntiAlias = true + textSize = SIGNATURE_LABEL_TEXT_SIZE + color = Color.BLACK + } + val signatureNameUnderLine = TextPaint().apply { + isAntiAlias = true + textSize = SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE + color = Color.BLACK + } + val signaturePlaceholder = TextPaint().apply { + isAntiAlias = true + textSize = SIGNATURE_PLACEHOLDER_TEXT_SIZE + color = Color.DKGRAY + } + // Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit. val checkboxOutline = Paint().apply { isAntiAlias = true @@ -301,6 +348,96 @@ class AndroidPdfDocumentExporter( } } + /** + * Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift + * "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil + * nach orderIndex sortiert, s. [PdfExportModel.signatures]). Anders als der Fotoanhang + * beginnt dieser Abschnitt NICHT zwingend auf einer neuen Seite - er schließt sich direkt an + * die zuletzt gezeichneten Dokumentationsblöcke an, sofern noch Platz vorhanden ist + * (Aufgabenstellung Abschnitt 25/28). + */ + private suspend fun drawSignatures( + cursor: PdfPageCursor, + model: PdfExportModel, + paints: ExportPaints, + retainedSignatureBitmaps: MutableList, + ) { + cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER) + for (signature in model.signatures) { + drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps) + } + } + + /** + * Zeichnet EINEN Signaturblock (Wundversorgername, Bild bzw. neutraler Platzhalter bei + * beschädigter/fehlender Datei, Unterschriftslinie, ausgeschriebener Name darunter) als + * unteilbare Einheit: Die Gesamthöhe wird VORAB berechnet und über + * [PdfPageCursor.ensureBlockSpace] reserviert, BEVOR die erste Teilzeichnung beginnt - reicht + * der Platz auf der aktuellen Seite nicht, wandert der komplette Block auf die nächste Seite, + * nie nur ein Teil davon (Aufgabenstellung Abschnitt 28). + * + * Bitmap-Lebensdauer: Die dekodierte Signatur-Bitmap wird - genau wie Foto-Bitmaps in + * [drawPhotoPage] - in [retainedSignatureBitmaps] gesammelt und erst nach `writeTo()` + * recycelt (s. Klassendokumentation zur PdfDocument-Speichersemantik). + */ + private suspend fun drawSignatureBlock( + cursor: PdfPageCursor, + model: PdfExportModel, + signature: PdfExportSignature, + paints: ExportPaints, + retainedSignatureBitmaps: MutableList, + ) { + val labelText = "Wundversorger: ${signature.signerName}" + val nameText = signature.signerName + + // Fehlende/beschädigte Signaturdatei führt NICHT zum Absturz des gesamten Exports - + // andere Signaturen und Fotos werden trotzdem exportiert (Aufgabenstellung Abschnitt 31). + // Bewusst OHNE Dateipfad/technischen Fehlertext im Platzhalter. + val bitmap = runCatching { + signatureStorage.decode(documentationId = model.documentationId, fileName = signature.fileName) + }.getOrNull() + + val labelHeight = cursor.measureTextHeight(labelText, paints.signatureLabel) + val imageOrPlaceholderHeight = if (bitmap != null) { + (SIGNATURE_IMAGE_TARGET_WIDTH_PT.coerceAtMost(cursor.contentWidth.toFloat())) * + bitmap.height / bitmap.width + } else { + cursor.measureTextHeight("Signatur konnte nicht geladen werden.", paints.signaturePlaceholder) + } + val nameHeight = cursor.measureTextHeight(nameText, paints.signatureNameUnderLine) + + val totalBlockHeight = labelHeight + SIGNATURE_SPACING_AFTER_LABEL + + imageOrPlaceholderHeight + SIGNATURE_SPACING_AFTER_IMAGE + + 1f + SIGNATURE_SPACING_AFTER_LINE + // Linie selbst ist ca. 1pt "hoch" + nameHeight + SIGNATURE_SPACING_AFTER_BLOCK + + // Reserviert die GESAMTE Blockhöhe in einem Rutsch, bevor überhaupt mit dem Zeichnen + // begonnen wird - das ist der eigentliche Mechanismus hinter "atomarer Block" (s. + // Methodenkommentar oben und PdfPageCursor.ensureBlockSpace). + cursor.ensureBlockSpace(totalBlockHeight) + + cursor.drawText(labelText, paints.signatureLabel, spacingAfter = SIGNATURE_SPACING_AFTER_LABEL) + + if (bitmap != null) { + retainedSignatureBitmaps.add(bitmap) + cursor.drawBitmapFixedWidth( + bitmap = bitmap, + targetWidth = SIGNATURE_IMAGE_TARGET_WIDTH_PT, + paint = paints.photo, + spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE, + ) + } else { + cursor.drawText( + "Signatur konnte nicht geladen werden.", + paints.signaturePlaceholder, + spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE, + ) + } + + cursor.drawShortLine(SIGNATURE_LINE_WIDTH_PT, spacingAfter = SIGNATURE_SPACING_AFTER_LINE) + cursor.drawText(nameText, paints.signatureNameUnderLine, spacingAfter = SIGNATURE_SPACING_AFTER_BLOCK) + } + /** * Zeichnet den Fotoanhang: jedes Foto auf einer eigenen, neu begonnenen Seite (PDF-004). * Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt index 000ab2f..e9f02b6 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt @@ -56,6 +56,13 @@ object DocumentationToPdfExportModelMapper { .sortedBy { it.orderIndex } .map { photo -> PdfExportPhoto(fileName = photo.fileName) } + // Explizit erneut nach orderIndex sortiert (dieselbe defensive Konsistenz wie bei + // blocks/items/photos oben) - alle gespeicherten Signaturen werden exportiert, es gibt + // keine feste Obergrenze und kein `signatures.first()` (Aufgabenstellung Abschnitt 29). + val signatures = documentation.signatures + .sortedBy { it.orderIndex } + .map { signature -> PdfExportSignature(signerName = signature.signerName, fileName = signature.fileName) } + return PdfExportModel( documentationId = documentation.id, fileName = fileName, @@ -64,6 +71,7 @@ object DocumentationToPdfExportModelMapper { patientDisplayAddress = snapshot.displayAddress, createdAt = createdAtDate, blocks = editedBlocks, + signatures = signatures, photos = photos, ) } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt index 8ee35ec..0a82e71 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt @@ -19,6 +19,9 @@ data class PdfExportModel( val createdAt: LocalDate, /** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */ val blocks: List, + /** Bereits sortiert nach orderIndex (PDF-003: Unterschriften). Reihenfolge im fertigen PDF: + * Dokumentationsblöcke -> Signaturen -> Fotoanhang. */ + val signatures: List, /** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */ val photos: List, ) @@ -45,3 +48,15 @@ data class PdfExportItem( data class PdfExportPhoto( val fileName: String, ) + +/** + * Minimale, für den Signaturbereich benötigte Referenz (PDF-003). [signerName] ist der + * historische Snapshot zum Zeitpunkt der Unterschrift - nicht der aktuelle Stand eventueller + * Wundversorger-Stammdaten. [fileName] wird analog [PdfExportPhoto] über + * [de.medapp.wunddoku.signature.SignatureStorage] aufgelöst (`documentationId` steht bereits am + * [PdfExportModel] zur Verfügung). + */ +data class PdfExportSignature( + val signerName: String, + val fileName: String, +) diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt index 23257e2..12b126a 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt @@ -145,6 +145,76 @@ internal class PdfPageCursor( } } + /** + * Öffentliches Gegenstück zu [ensureSpace] für ATOMARE Blöcke, die aus mehreren + * Einzelzeichnungen bestehen (PDF-003: ein Signaturblock aus Wundversorgername, Bild, Linie + * und Namenszeile darf laut Aufgabenstellung Abschnitt 28 nicht zwischen zwei Seiten + * zerrissen werden). Der Aufrufer berechnet VOR dem eigentlichen Zeichnen die + * Gesamthöhe des Blocks (s. [measureTextHeight] für die enthaltenen Textanteile) und + * reserviert sie hier in einem Rutsch - reicht der verbleibende Platz nicht, beginnt eine + * komplett neue Seite, BEVOR auch nur die erste Teilzeichnung des Blocks beginnt. Anders als + * [drawText] (das einen zu langen Text selbst auf mehrere Seiten verteilen würde) gibt es + * hier keine interne Aufteilung - das ist hier gewünscht, kein Sonderfall. + */ + fun ensureBlockSpace(height: Float) { + ensureSpace(height) + } + + /** + * Berechnet die Gesamthöhe, die [text] beim Zeichnen mit [drawText] belegen würde, OHNE + * etwas zu zeichnen oder den Cursor zu verändern. Verwendet zur Berechnung dasselbe + * [buildLayout] wie [drawText] selbst (identischer Text/Paint/Breite), damit die hier + * gemessene und die später tatsächlich gezeichnete Höhe für ein und denselben Aufruf + * garantiert übereinstimmen - Voraussetzung dafür, dass eine vorab über [ensureBlockSpace] + * reservierte Blockhöhe beim tatsächlichen Zeichnen exakt ausreicht. + */ + fun measureTextHeight(text: String, paint: TextPaint, indent: Float = 0f): Float { + val width = (contentWidth - indent).toInt().coerceAtLeast(1) + return buildLayout(text, paint, width).height.toFloat() + } + + /** + * Zeichnet [bitmap] proportional in eine FESTE Zielbreite [targetWidth] eingepasst (Höhe + * ergibt sich aus dem Seitenverhältnis von [bitmap] - kein Zuschneiden, keine Verzerrung), + * linksbündig ab der aktuellen Cursor-Position. + * + * Bewusst ein GRUNDSÄTZLICH ANDERES Verhalten als [drawBitmapFitted] (PDF-004: füllt gezielt + * den GESAMTEN verbleibenden Platz der Seite - richtig für "genau ein Foto pro Seite"): Hier + * wird NICHT der restliche Seitenplatz ausgefüllt, sondern exakt die aus [targetWidth] + * abgeleitete Zielhöhe gezeichnet und der Cursor auch nur genau um diese Höhe + * weiterbewegt - Voraussetzung dafür, dass mehrere Signaturblöcke hintereinander auf + * derselben Seite stehen können, statt dass jeder einzelne Block wie ein Foto seine ganze + * Seite beansprucht (Aufgabenstellung Abschnitt 28: "Mehrere vollständige Signaturblöcke + * dürfen auf derselben Seite stehen, sofern genug Platz vorhanden ist"). + * + * Setzt voraus, dass für den vollständigen Block (inkl. dieses Bildes) bereits vorher + * [ensureBlockSpace] aufgerufen wurde - diese Methode selbst löst KEINEN Seitenumbruch aus. + */ + fun drawBitmapFixedWidth(bitmap: Bitmap, targetWidth: Float, paint: Paint, spacingAfter: Float = 0f): Float { + startIfNeeded() + if (bitmap.width <= 0 || bitmap.height <= 0) return 0f + val drawWidth = targetWidth.coerceAtMost(contentWidth.toFloat()) + val drawHeight = drawWidth * bitmap.height / bitmap.width + val left = marginLeft + val top = cursorY + canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint) + cursorY += drawHeight + spacingAfter + return drawHeight + } + + /** + * Kurze horizontale Linie fester Breite [width] (z. B. eine Unterschriftslinie), linksbündig + * ab der aktuellen Cursor-Position - im Gegensatz zu [drawDivider] NICHT über die volle + * Inhaltsbreite der Seite gezogen. Setzt wie [drawBitmapFixedWidth] ein vorheriges + * [ensureBlockSpace] für den gesamten umgebenden Block voraus. + */ + fun drawShortLine(width: Float, spacingBefore: Float = 0f, spacingAfter: Float = 0f) { + startIfNeeded() + cursorY += spacingBefore + canvas!!.drawLine(marginLeft, cursorY, marginLeft + width, cursorY, dividerPaint) + cursorY += spacingAfter + } + /** * Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert * über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt new file mode 100644 index 0000000..dc9066a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt @@ -0,0 +1,29 @@ +package de.medapp.wunddoku.signature + +import android.graphics.Bitmap + +/** + * Zweite, von der UI unabhängige Absicherung gegen das Speichern einer leeren Signatur + * (Aufgabenstellung Abschnitt 12: "Daten-/Storage-Ebene: keine leere PNG-Datei als gültige + * Signatur persistieren"). Die erste Ebene ist der deaktivierte "Speichern"-Button in der UI, + * solange keine Striche gezeichnet wurden - diese Prüfung greift zusätzlich unabhängig davon in + * [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature], damit ein Aufruf, + * der die UI-Absicherung umgeht (Programmierfehler, zukünftiger zweiter Aufrufer o. Ä.), nicht + * trotzdem eine leere Signatur persistiert. + */ +object SignatureBitmapValidation { + + /** + * Liefert `true`, wenn [bitmap] ausschließlich reinweiße Pixel enthält, also keine + * tatsächlich gezeichnete Signatur darstellt. Die Signaturzeichenfläche rendert auf einen + * weißen Hintergrund; jeder gezeichnete Strich (auch nur teilweise, dank Anti-Aliasing an + * den Rändern) erzeugt mindestens einen Pixel, der von reinem Weiß abweicht. + */ + fun isBlank(bitmap: Bitmap): Boolean { + if (bitmap.width <= 0 || bitmap.height <= 0) return true + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val opaqueWhite = 0xFFFFFFFF.toInt() + return pixels.none { pixel -> pixel != opaqueWhite } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt new file mode 100644 index 0000000..29b205f --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt @@ -0,0 +1,132 @@ +package de.medapp.wunddoku.signature + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.util.UUID + +/** + * Verwaltet die physischen Signatur-PNGs im internen App-Speicher, organisiert pro + * Dokumentation - exakt dieselbe Struktur-Idee wie + * [de.medapp.wunddoku.media.PhotoStorage]: + * + * ``` + * filesDir/ + * └── signatures/ + * └── / + * └── .png + * ``` + * + * Ersetzt den ursprünglichen Platzhalter `SignatureFileStorage`, der noch flach unter + * `filesDir/signatures/.png` speicherte (ohne Bezug zur jeweiligen Dokumentation). Ein + * eigenes Unterverzeichnis pro Dokumentation hat denselben Vorteil wie bei Fotos: [deleteAll] + * kann beim Löschen einer kompletten Dokumentation das gesamte Verzeichnis in einem Schritt + * entfernen, statt Einzeldateien anhand von DB-Zeilen durchzugehen (Aufgabenstellung + * Abschnitt 13/18). + * + * Persistiert wird ausschließlich das fertige, bereits auf das logische 1000x400-Format + * normalisierte Signaturbild als PNG (verlustfrei) - Stroke-/Pfad-Daten sind ausschließlich + * Editor-State und werden hier nie entgegengenommen (Aufgabenstellung Abschnitt 11). Alle + * Operationen laufen über [Dispatchers.IO], nie auf dem Hauptthread. + */ +interface SignatureStorage { + + /** + * Speichert [bitmap] als neue PNG-Datei im dokumentationsspezifischen Verzeichnis. Liefert + * den relativen Dateinamen (ausschließlich der documentationId, s. [resolve]) - kein + * absoluter, geräteabhängiger Pfad wird persistiert (Aufgabenstellung Abschnitt 15/20). + */ + suspend fun save(documentationId: String, bitmap: Bitmap): String + + /** Löscht eine einzelne physische Signaturdatei. Wirft KEINE Exception, wenn die Datei + * nicht (mehr) existiert - analog [de.medapp.wunddoku.media.PhotoStorage.delete]. */ + suspend fun delete(documentationId: String, fileName: String) + + /** + * Löscht das gesamte Signaturverzeichnis einer Dokumentation samt aller enthaltenen Dateien + * in einem Schritt (analog [de.medapp.wunddoku.media.PhotoStorage.deleteAll]). Verwendet + * beim Löschen der kompletten Dokumentation - unabhängig davon, welche DB-Zeilen zu diesem + * Zeitpunkt noch bekannt sind, damit keine verwaisten PNG-Dateien zurückbleiben. + */ + suspend fun deleteAll(documentationId: String) + + /** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. */ + fun resolve(documentationId: String, fileName: String): File + + /** + * Decodiert die gespeicherte PNG-Datei in voller Auflösung. Kein Downsampling nötig - das + * logische Signaturformat ist mit 1000x400px bereits klein (siehe Zeichenfläche), anders + * als importierte Fotos. Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden + * kann - PDF-003 (Aufgabenstellung Abschnitt 31) zeigt in diesem Fall einen neutralen + * Platzhalter statt abzustürzen. + */ + suspend fun decode(documentationId: String, fileName: String): Bitmap? +} + +/** + * Generische Implementierung, organisiert pro "Schlüssel" (bei dokumentationsbezogenen + * Signaturen: `documentationId`) in einem eigenen Wurzelverzeichnis [rootDirName]. Wird + * ZWEIMAL instanziiert (s. [de.medapp.wunddoku.di.AppContainer]): + * + * - einmal für die historischen, unveränderlichen Signaturen einzelner Dokumentationen + * (`rootDirName = "signatures"`, Schlüssel = documentationId), + * - einmal für das Wundversorger-Register mit jeweils nur der AKTUELLSTEN Signatur je Person + * (`rootDirName = "wound_carer_signatures"`, Schlüssel = woundCarerId) - s. + * [de.medapp.wunddoku.data.repository.WoundCarerRepository]. + * + * Beide Verwendungen sind vom Namen her identisch aufgebaut (`filesDir///`), + * aber vollständig unabhängige Verzeichnisbäume - eine gelöschte Dokumentation räumt z. B. NIE + * das Wundversorger-Register mit auf, und umgekehrt. + */ +class InternalSignatureStorage( + private val context: Context, + private val rootDirName: String = "signatures", +) : SignatureStorage { + + private val rootDir: File by lazy { + File(context.filesDir, rootDirName).apply { mkdirs() } + } + + private fun documentationDirPath(documentationId: String): File = + File(rootDir, documentationId) + + private fun ensureDocumentationDir(documentationId: String): File = + documentationDirPath(documentationId).apply { mkdirs() } + + override fun resolve(documentationId: String, fileName: String): File = + File(documentationDirPath(documentationId), fileName) + + override suspend fun save(documentationId: String, bitmap: Bitmap): String = + withContext(Dispatchers.IO) { + val fileName = "${UUID.randomUUID()}.png" + val targetDir = ensureDocumentationDir(documentationId) + FileOutputStream(File(targetDir, fileName)).use { out -> + // Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem + // explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format + // verlangt. + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + fileName + } + + override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) { + resolve(documentationId, fileName).delete() + Unit + } + + override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) { + documentationDirPath(documentationId).deleteRecursively() + Unit + } + + override suspend fun decode(documentationId: String, fileName: String): Bitmap? = + withContext(Dispatchers.IO) { + val file = resolve(documentationId, fileName) + if (!file.exists()) return@withContext null + runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull() + } +} 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 2253187..9acf19a 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 @@ -69,9 +69,11 @@ 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 de.medapp.wunddoku.domain.model.Signature import de.medapp.wunddoku.pdf.AndroidPdfOpener import de.medapp.wunddoku.pdf.PdfOpenResult import de.medapp.wunddoku.pdf.toUserMessage +import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog /** * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit @@ -201,6 +203,7 @@ fun DocumentationEditScreen( modifier = Modifier.fillMaxSize().padding(padding), block = currentBlock, photos = documentation.photos, + signatures = documentation.signatures, onCheckedChanged = viewModel::onCheckedChanged, onFreeTextChanged = viewModel::onFreeTextChanged, canGoBack = uiState.currentBlockIndex > 0, @@ -217,6 +220,9 @@ fun DocumentationEditScreen( }, onDeletePhotoRequested = viewModel::requestDeletePhoto, loadThumbnail = viewModel::loadPhotoThumbnail, + onAddSignatureClick = viewModel::requestAddSignature, + onDeleteSignatureRequested = viewModel::requestDeleteSignature, + loadSignatureBitmap = viewModel::loadSignatureBitmap, ) } } @@ -241,6 +247,54 @@ fun DocumentationEditScreen( }, ) } + + // MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene + // Navigationsroute, s. Aufgabenstellung Abschnitt 7). + if (uiState.showAddSignatureDialog) { + AddSignatureDialog( + onDismiss = viewModel::dismissAddSignature, + onSave = viewModel::saveSignature, + loadDefaultWoundCarer = viewModel::getDefaultWoundCarer, + searchWoundCarers = viewModel::searchWoundCarers, + loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap, + ) + } + + // Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung + // Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher + // deaktiviert, solange Name oder Zeichnung leer sind. + val signatureError = uiState.signatureError + if (signatureError != null) { + AlertDialog( + onDismissRequest = viewModel::dismissSignatureError, + title = { Text(stringResource(R.string.documentation_edit_signatures_title)) }, + text = { Text(signatureError) }, + confirmButton = { + TextButton(onClick = viewModel::dismissSignatureError) { + Text(stringResource(R.string.action_confirm)) + } + }, + ) + } + + val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId + if (pendingDeleteSignatureId != null) { + AlertDialog( + onDismissRequest = viewModel::dismissDeleteSignature, + title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) }, + text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) }, + confirmButton = { + TextButton(onClick = viewModel::confirmDeleteSignature) { + Text(stringResource(R.string.action_confirm)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::dismissDeleteSignature) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } } /** @@ -254,6 +308,7 @@ private fun DocumentationEditContent( modifier: Modifier = Modifier, block: DocumentationBlock, photos: List, + signatures: List, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, canGoBack: Boolean, @@ -266,6 +321,9 @@ private fun DocumentationEditContent( onAddPhotosClick: () -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, + onAddSignatureClick: () -> Unit, + onDeleteSignatureRequested: (signatureId: String) -> Unit, + loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, ) { LazyColumn( modifier = modifier, @@ -302,6 +360,47 @@ private fun DocumentationEditContent( } } + // MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt + // darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im + // späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003). + item(key = "signatures_header") { + Column { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.documentation_edit_signatures_title), + style = MaterialTheme.typography.titleMedium, + ) + OutlinedButton(onClick = onAddSignatureClick) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.documentation_edit_add_signature)) + } + } + } + } + + if (signatures.isEmpty()) { + item(key = "signatures_empty") { + Text( + stringResource(R.string.documentation_edit_no_signatures), + style = MaterialTheme.typography.bodySmall, + ) + } + } else { + items(signatures, key = { it.id }) { signature -> + SignatureRow( + signature = signature, + loadBitmap = loadSignatureBitmap, + onDeleteRequested = { onDeleteSignatureRequested(signature.id) }, + ) + } + } + item(key = "photos_header") { Column { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) @@ -440,6 +539,78 @@ private fun PhotoThumbnail( } } +/** + * Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und + * Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre + * Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) - + * analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel. + * Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es + * hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen. + */ +@Composable +private fun SignatureRow( + signature: Signature, + loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, + onDeleteRequested: () -> Unit, +) { + var bitmap by remember(signature.id) { mutableStateOf(null) } + var loadFailed by remember(signature.id) { mutableStateOf(false) } + + LaunchedEffect(signature.id) { + val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull() + bitmap = result + loadFailed = result == null + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .width(96.dp) + .size(96.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + ) { + val currentBitmap = bitmap + when { + currentBitmap != null -> Image( + bitmap = currentBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + + loadFailed -> Icon( + Icons.Filled.BrokenImage, + contentDescription = null, + modifier = Modifier.align(Alignment.Center), + ) + + else -> CircularProgressIndicator( + modifier = Modifier.size(24.dp).align(Alignment.Center), + strokeWidth = 2.dp, + ) + } + } + + Text( + text = signature.signerName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + + IconButton(onClick = onDeleteRequested) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.documentation_action_delete), + ) + } + } +} + @Composable private fun CheckItemRow( item: CheckItem, 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 18b97ab..326c4f3 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 @@ -1,5 +1,6 @@ package de.medapp.wunddoku.ui.documentation.edit +import android.graphics.Bitmap import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.createSavedStateHandle @@ -8,8 +9,10 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import androidx.navigation.toRoute import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.data.repository.WoundCarerRepository import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.domain.model.Documentation +import de.medapp.wunddoku.domain.model.WoundCarer import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.ui.navigation.Screen @@ -35,6 +38,14 @@ 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, solange der "Signatur hinzufügen"-Dialog angezeigt wird (MEDIA-003). */ + val showAddSignatureDialog: Boolean = false, + /** Fehlermeldung aus einer fehlgeschlagenen Signatur-Validierung (zweite, von der UI + * unabhängige Absicherungsebene in [DocumentationRepository.saveSignature] - im Normalfall + * verhindert bereits der deaktivierte "Speichern"-Button im Dialog diesen Fall). */ + val signatureError: String? = null, + /** != null, solange die Löschbestätigung für eine Signatur angezeigt werden soll. */ + val pendingDeleteSignatureId: 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 @@ -68,6 +79,7 @@ data class DocumentationEditUiState( class DocumentationEditViewModel( private val documentationId: String, private val repository: DocumentationRepository, + private val woundCarerRepository: WoundCarerRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(DocumentationEditUiState()) @@ -237,6 +249,68 @@ class DocumentationEditViewModel( } } + /** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */ + fun requestAddSignature() { + _uiState.update { it.copy(showAddSignatureDialog = true) } + } + + fun dismissAddSignature() { + _uiState.update { it.copy(showAddSignatureDialog = false) } + } + + /** + * Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische + * 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]). + * Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst + * NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s. + * [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern" + * bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte. + */ + fun saveSignature(signerName: String, bitmap: Bitmap) { + viewModelScope.launch { + try { + repository.saveSignature(documentationId, signerName, bitmap) + val refreshed = repository.getFullDocumentation(documentationId) + _uiState.update { state -> + state.copy( + documentation = refreshed ?: state.documentation, + showAddSignatureDialog = false, + ) + } + } catch (e: IllegalArgumentException) { + _uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") } + } + } + } + + fun dismissSignatureError() { + _uiState.update { it.copy(signatureError = null) } + } + + /** Öffnet die Löschbestätigung für eine Signatur (Aufgabenstellung Abschnitt 17: Signaturen + * sind unveränderlich - "korrigieren" bedeutet immer löschen + neu erfassen). */ + fun requestDeleteSignature(signatureId: String) { + _uiState.update { it.copy(pendingDeleteSignatureId = signatureId) } + } + + fun dismissDeleteSignature() { + _uiState.update { it.copy(pendingDeleteSignatureId = null) } + } + + fun confirmDeleteSignature() { + val signatureId = _uiState.value.pendingDeleteSignatureId ?: return + viewModelScope.launch { + repository.removeSignature(signatureId) + val refreshed = repository.getFullDocumentation(documentationId) + _uiState.update { state -> + state.copy( + documentation = refreshed ?: state.documentation, + pendingDeleteSignatureId = null, + ) + } + } + } + /** * 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 @@ -291,6 +365,25 @@ class DocumentationEditViewModel( suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = repository.loadPhotoThumbnail(documentationId, fileName) + /** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog + * [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */ + suspend fun loadSignatureBitmap(documentationId: String, fileName: String) = + repository.loadSignatureBitmap(documentationId, fileName) + + /** Für die Vorbelegung des "Signatur hinzufügen"-Dialogs beim Öffnen: der zuletzt + * verwendete Wundversorger app-weit (nicht auf diese Dokumentation beschränkt) - `null`, + * wenn noch nie eine Signatur gespeichert wurde. Weiterhin änderbar (Änderungswunsch). */ + suspend fun getDefaultWoundCarer(): WoundCarer? = woundCarerRepository.getMostRecentlyUsed() + + /** Namensvorschläge für die Autovervollständigung im Signatur-Dialog. */ + suspend fun searchWoundCarers(query: String): List = woundCarerRepository.search(query) + + /** Lädt die hinterlegte Signatur eines bekannten Wundversorgers (Vorbelegung bzw. nach Tippen + * auf einen Namensvorschlag) - unabhängig von [loadSignatureBitmap], das historische, pro + * Dokumentation gespeicherte Signaturen lädt. */ + suspend fun loadWoundCarerSignatureBitmap(woundCarerId: String, fileName: String) = + woundCarerRepository.loadSignatureBitmap(woundCarerId, fileName) + /** * Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom * Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei @@ -319,6 +412,7 @@ class DocumentationEditViewModel( DocumentationEditViewModel( documentationId = route.documentationId, repository = container.documentationRepository, + woundCarerRepository = container.woundCarerRepository, ) } } diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt new file mode 100644 index 0000000..4f6e290 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt @@ -0,0 +1,235 @@ +package de.medapp.wunddoku.ui.documentation.edit.signature + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import de.medapp.wunddoku.R +import de.medapp.wunddoku.domain.model.WoundCarer +import kotlinx.coroutines.launch + +/** + * Dialog zum Erfassen einer neuen Signatur (MEDIA-003): Name des Wundversorgers + Zeichenfläche + * ([SignaturePad]), wie in der Aufgabenstellung (Abschnitt 7) als bevorzugte UX beschrieben - + * bewusst als Dialog INNERHALB von [de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen], + * keine eigene Navigationsroute. + * + * Änderungswunsch (Wundversorger-Register): Beim Öffnen wird automatisch der ZULETZT verwendete + * Wundversorger samt seiner zuletzt gespeicherten Signatur vorbelegt ([loadDefaultWoundCarer]) - + * bleibt aber änderbar. Während der Eingabe werden nach den ersten Buchstaben passende bekannte + * Namen als antippbare Vorschläge angezeigt ([searchWoundCarers]); ein Tippen darauf übernimmt + * Name UND zuletzt gespeicherte Signatur dieses Wundversorgers ([loadWoundCarerSignatureBitmap]). + * In diesem "Vorschau-Modus" wird die geladene Signatur als fertiges Bild angezeigt (nicht mehr + * frei bearbeitbar) - über "Neu zeichnen" kann jederzeit zu einer frischen, freihändigen + * Zeichnung für denselben Namen gewechselt werden. Wird der Name manuell so verändert, dass er + * nicht mehr zum gerade geladenen Vorschlag passt, wird die Vorschau verworfen (verhindert, dass + * versehentlich die Signatur einer anderen Person unter einem neuen Namen gespeichert wird). + * + * "Speichern" ist erst aktiv, wenn BEIDE Bedingungen erfüllt sind (erste Absicherungsebene gegen + * leere Signaturen bzw. leeren Namen, s. Aufgabenstellung Abschnitt 12 - die zweite, unabhängige + * Ebene sitzt serverseitig in + * [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature]): + * - der (getrimmte) Name ist nicht leer + * - es liegt entweder eine geladene Vorschau-Signatur ODER eine frisch gezeichnete Signatur vor + */ +@Composable +fun AddSignatureDialog( + onDismiss: () -> Unit, + onSave: (signerName: String, bitmap: Bitmap) -> Unit, + loadDefaultWoundCarer: suspend () -> WoundCarer?, + searchWoundCarers: suspend (query: String) -> List, + loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?, +) { + var signerName by remember { mutableStateOf("") } + var strokes by remember { mutableStateOf>(emptyList()) } + var canvasSize by remember { mutableStateOf(Size.Zero) } + + // != null => Vorschau-Modus: eine bereits bekannte Signatur wird als fertiges Bild gezeigt + // statt einer leeren, frei bearbeitbaren Zeichenfläche. + var previewBitmap by remember { mutableStateOf(null) } + // Name, zu dem [previewBitmap] gehört - dient ausschließlich dazu, die Vorschau zu verwerfen, + // sobald der Name manuell auf etwas anderes geändert wird (s. Klassenkommentar). + var previewOwnerName by remember { mutableStateOf(null) } + var suggestions by remember { mutableStateOf>(emptyList()) } + + val scope = rememberCoroutineScope() + + // Einmalige Vorbelegung beim Öffnen mit dem zuletzt verwendeten Wundversorger. + LaunchedEffect(Unit) { + val default = loadDefaultWoundCarer() ?: return@LaunchedEffect + signerName = default.name + previewOwnerName = default.name + previewBitmap = loadWoundCarerSignatureBitmap(default.id, default.signatureFileName) + } + + // Vorschläge nachladen UND Vorschau verwerfen, sobald der Name vom Vorschau-Eigentümer + // abweicht (z. B. weil der Nutzer den vorbelegten Namen überschreibt). + LaunchedEffect(signerName) { + if (previewOwnerName != null && signerName != previewOwnerName) { + previewBitmap = null + previewOwnerName = null + } + suggestions = if (previewOwnerName == signerName) { + emptyList() + } else { + searchWoundCarers(signerName) + } + } + + val canSave = signerName.trim().isNotEmpty() && (previewBitmap != null || strokes.isNotEmpty()) + + Dialog(onDismissRequest = onDismiss) { + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) { + Text( + stringResource(R.string.signature_dialog_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = signerName, + onValueChange = { signerName = it }, + label = { Text(stringResource(R.string.signature_dialog_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (suggestions.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(suggestions, key = { it.id }) { suggestion -> + AssistChip( + onClick = { + signerName = suggestion.name + previewOwnerName = suggestion.name + suggestions = emptyList() + scope.launch { + previewBitmap = loadWoundCarerSignatureBitmap( + suggestion.id, + suggestion.signatureFileName, + ) + } + }, + label = { Text(suggestion.name) }, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + val currentPreview = previewBitmap + if (currentPreview != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) + .background(Color.White, RoundedCornerShape(8.dp)), + ) { + Image( + bitmap = currentPreview.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxWidth().height(180.dp).padding(8.dp), + contentScale = ContentScale.Fit, + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { + previewBitmap = null + strokes = emptyList() + }) { + Text(stringResource(R.string.signature_dialog_redraw)) + } + } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) + .background(Color.White, RoundedCornerShape(8.dp)) + .onSizeChanged { size -> canvasSize = Size(size.width.toFloat(), size.height.toFloat()) }, + ) { + SignaturePad( + strokes = strokes, + onStrokesChanged = { strokes = it }, + modifier = Modifier.fillMaxWidth().height(180.dp), + ) + } + Text( + stringResource(R.string.signature_dialog_single_stroke_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { strokes = emptyList() }, enabled = strokes.isNotEmpty()) { + Text(stringResource(R.string.signature_dialog_reset)) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + val bitmap = previewBitmap ?: renderSignatureBitmap(strokes, canvasSize) + onSave(signerName.trim(), bitmap) + }, + enabled = canSave, + ) { + Text(stringResource(R.string.signature_dialog_save)) + } + } + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt new file mode 100644 index 0000000..6f4f30e --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt @@ -0,0 +1,156 @@ +package de.medapp.wunddoku.ui.documentation.edit.signature + +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput + +/** Logisches Signaturformat, unabhängig von der tatsächlichen Größe der Compose-Zeichenfläche + * auf einem konkreten Gerät (Aufgabenstellung Abschnitt 10). NUR für die persistierte Bitmap + * relevant - die UI selbst darf/soll responsiv sein. */ +const val SIGNATURE_LOGICAL_WIDTH = 1000 +const val SIGNATURE_LOGICAL_HEIGHT = 400 + +/** Strichbreite in Bildschirm-dp während des Zeichnens. Die tatsächliche, im PNG persistierte + * Strichbreite ergibt sich aus [SIGNATURE_LOGICAL_STROKE_WIDTH] nach der Normalisierung. */ +private const val SIGNATURE_UI_STROKE_WIDTH_PX = 5f +private const val SIGNATURE_LOGICAL_STROKE_WIDTH = 6f + +/** + * Eine einzelne, zusammenhängende Zeichenbewegung (ein "Strich") in tatsächlichen + * Canvas-Pixelkoordinaten (nicht normalisiert). + */ +typealias SignatureStroke = List + +/** + * Compose-basierte Signaturzeichenfläche (MEDIA-003). Unterstützt Finger UND Stylus ohne + * Sonderbehandlung - Compose liefert beide über dieselbe Zeigereingabe-Pipeline + * ([pointerInput]/[detectDragGestures]), es gibt auf Android keinen technischen Unterschied + * zwischen einer Finger- und einer Stylus-Berührung an dieser Stelle. + * + * FACHLICHE EINSCHRÄNKUNG (Änderungswunsch): Eine gültige Signatur besteht aus GENAU EINEM + * durchgehenden Strich - ein erneutes Ansetzen (zweiter Touch-Down, nachdem bereits ein Strich + * gezeichnet wurde) verwirft die komplette bisherige Zeichnung automatisch und beginnt neu mit + * genau diesem neuen Strich. Es kann also beliebig oft neu versucht werden, aber niemals mehrere + * getrennte Striche zu EINER Signatur zusammengesetzt werden. Umgesetzt, indem [onDragEnd] die + * nach oben gemeldete Strichliste immer komplett ERSETZT (`listOf(currentStroke)`) statt an + * [strokes] anzuhängen - dadurch kann zu jedem Zeitpunkt höchstens ein abgeschlossener Strich in + * [strokes] stehen, unabhängig von der Reihenfolge der Ereignisse. + * + * Zustandslos (state hoisting): [strokes] kommt vom Aufrufer, jede Änderung wird über + * [onStrokesChanged] nach oben gemeldet. + */ +@Composable +fun SignaturePad( + strokes: List, + onStrokesChanged: (List) -> Unit, + modifier: Modifier = Modifier, +) { + var currentStroke by remember { mutableStateOf(emptyList()) } + + Canvas( + modifier = modifier + .background(Color.White) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> currentStroke = listOf(offset) }, + onDrag = { change, _ -> + change.consume() + currentStroke = currentStroke + change.position + }, + onDragEnd = { + // Ein "Tap" ohne tatsächliche Bewegung (< 2 Punkte) erzeugt bewusst + // keinen sichtbaren Strich - eine bereits vorhandene, gültige Signatur + // bleibt in diesem Fall unangetastet erhalten. Ein ECHTER Strich ersetzt + // dagegen immer die komplette bisherige Zeichnung (s. Klassenkommentar). + if (currentStroke.size > 1) { + onStrokesChanged(listOf(currentStroke)) + } + currentStroke = emptyList() + }, + onDragCancel = { currentStroke = emptyList() }, + ) + }, + ) { + for (stroke in strokes) { + drawSignatureStroke(stroke) + } + if (currentStroke.size > 1) { + drawSignatureStroke(currentStroke) + } + } +} + +private fun DrawScope.drawSignatureStroke(points: SignatureStroke) { + if (points.size < 2) return + val path = Path().apply { + moveTo(points.first().x, points.first().y) + for (index in 1 until points.size) { + lineTo(points[index].x, points[index].y) + } + } + drawPath( + path = path, + color = Color.Black, + style = Stroke( + width = SIGNATURE_UI_STROKE_WIDTH_PX, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) +} + +/** + * Rendert die erfassten [strokes] (in tatsächlichen Canvas-Pixelkoordinaten der Größe + * [canvasSize]) auf ein neues, weißes Bitmap im logischen Signaturformat + * ([SIGNATURE_LOGICAL_WIDTH] x [SIGNATURE_LOGICAL_HEIGHT]) - unabhängig von der tatsächlichen + * Gerätegröße der Zeichenfläche (Aufgabenstellung Abschnitt 10: Normalisierung). Persistiert + * wird ausschließlich dieses fertige Bitmap, niemals die Stroke-Rohdaten selbst + * (Aufgabenstellung Abschnitt 11). + */ +fun renderSignatureBitmap(strokes: List, canvasSize: Size): Bitmap { + val bitmap = Bitmap.createBitmap(SIGNATURE_LOGICAL_WIDTH, SIGNATURE_LOGICAL_HEIGHT, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bitmap) + canvas.drawColor(android.graphics.Color.WHITE) + + if (canvasSize.width <= 0f || canvasSize.height <= 0f) return bitmap + + val scaleX = SIGNATURE_LOGICAL_WIDTH / canvasSize.width + val scaleY = SIGNATURE_LOGICAL_HEIGHT / canvasSize.height + + val paint = android.graphics.Paint().apply { + isAntiAlias = true + color = android.graphics.Color.BLACK + style = android.graphics.Paint.Style.STROKE + strokeWidth = SIGNATURE_LOGICAL_STROKE_WIDTH + strokeCap = android.graphics.Paint.Cap.ROUND + strokeJoin = android.graphics.Paint.Join.ROUND + } + + for (stroke in strokes) { + if (stroke.size < 2) continue + val path = android.graphics.Path() + path.moveTo(stroke.first().x * scaleX, stroke.first().y * scaleY) + for (index in 1 until stroke.size) { + path.lineTo(stroke[index].x * scaleX, stroke[index].y * scaleY) + } + canvas.drawPath(path, paint) + } + + return bitmap +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c8932e..9634c39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,19 @@ Foto löschen? Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden. + Unterschriften + Signatur hinzufügen + Noch keine Unterschriften vorhanden. + Unterschrift löschen? + Diese Unterschrift wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden. + + Signatur hinzufügen + Name des Wundversorgers + Zurücksetzen + Speichern + Neu zeichnen + Bitte in einem Zug unterschreiben, ohne abzusetzen - bei erneutem Ansetzen wird die Zeichnung zurückgesetzt. + Abbrechen Bestätigen