Beta 1.0 Release #11

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