Compare commits

...

3 Commits

Author SHA1 Message Date
niklas.ortmann e9403801d6 Merge pull request 'Kamera-Button + neue Inhalte' (#17) from dev-Inhalte3 into dev
Reviewed-on: #17
2026-10-12 01:58:20 +02:00
NOrtmann1 18a7af800f Kamera-Button + neue Inhalte 2026-10-12 01:57:56 +02:00
niklas.ortmann 51370a9172 Merge pull request 'QA-001' (#15) from dev-QA001 into dev
Reviewed-on: #15
2026-10-09 10:02:08 +02:00
7 changed files with 251 additions and 79 deletions
@@ -28,11 +28,11 @@ import de.medapp.wunddoku.domain.model.InputSpec
* Abschnitt 2.3b. * Abschnitt 2.3b.
* *
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives * [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
* Metadatum in Documentation.catalogVersion übernommen. Auf 4 erhöht mit diesem Umbau. * Metadatum in Documentation.catalogVersion übernommen. Auf 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
*/ */
object DocumentationCatalog { object DocumentationCatalog {
const val CATALOG_VERSION: Int = 4 const val CATALOG_VERSION: Int = 5
/** /**
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert, * Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
@@ -50,7 +50,6 @@ object DocumentationCatalog {
"0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm", "0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm",
"4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm", "4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm",
) )
private val DECUBITUS_GRADE_OPTIONS: List<String> = listOf("I", "II", "III", "IV")
val blocks: List<BlockDefinition> = listOf( val blocks: List<BlockDefinition> = listOf(
BlockDefinition( BlockDefinition(
@@ -60,15 +59,7 @@ object DocumentationCatalog {
items = listOf( items = listOf(
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1), CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true), CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
CheckItemDefinition( CheckItemDefinition(id = "wundart_decubitus", label = "Decubitus", order = 3),
id = "wundart_decubitus",
label = "Decubitus",
order = 3,
inputSpec = InputSpec(
pdfTemplate = "Decubitus Grad {0}",
fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)),
),
),
CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4), CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5), CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5),
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6), CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
@@ -507,7 +498,9 @@ object DocumentationCatalog {
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2), CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3), CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4), CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5), // Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
// Checkbox aktiv ist (= vorhandenes isFreeText-Verhalten, s. FreeTextCheckItemRow).
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5, isFreeText = true),
CheckItemDefinition( CheckItemDefinition(
id = "empfohlene_wundversorgung_getraenkte_kompresse", id = "empfohlene_wundversorgung_getraenkte_kompresse",
label = "Getränkte Kompresse", label = "Getränkte Kompresse",
@@ -523,36 +516,43 @@ object DocumentationCatalog {
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10), CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11), CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12), CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 15), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16), CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 16),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 17),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_freitext", label = FREE_TEXT_LABEL, order = 18, isFreeText = true),
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
CheckItemDefinition( CheckItemDefinition(
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche", id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
label = "Wie oft pro Woche?", label = "Wie oft pro Woche",
order = 17, order = 19,
inputSpec = InputSpec( inputSpec = InputSpec(
pdfTemplate = "{0}x pro Woche", pdfTemplate = "{0}x pro Woche",
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)), fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
requiresOwnCheckbox = true,
), ),
), ),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 20, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19), CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 21),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20), CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23), CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24), CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25), CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26), CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28), CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 32, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 33, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 34, isStaticHint = true),
CheckItemDefinition( CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung", id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung", label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 31, order = 35,
isStaticHint = true, isStaticHint = true,
), ),
), ),
@@ -46,6 +46,14 @@ sealed interface InputFieldDef {
data class InputSpec( data class InputSpec(
val pdfTemplate: String, val pdfTemplate: String,
val fields: List<InputFieldDef>, val fields: List<InputFieldDef>,
/**
* `true`: der Eintrag hat zusätzlich eine EIGENE Checkbox mit dem Label des Eintrags (z. B.
* "Wie oft pro Woche"). Die Eingabefelder sind dann nur aktiv, solange diese Checkbox
* angehakt ist; das Häkchen wird NICHT mehr automatisch aus den Feldwerten abgeleitet,
* sondern ausschließlich vom Nutzer gesetzt. Standard `false` = bisheriges Verhalten
* (Felder immer aktiv, Häkchen automatisch bei mindestens einem Wert).
*/
val requiresOwnCheckbox: Boolean = false,
) { ) {
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */ /** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
@@ -86,7 +94,7 @@ data class InputSpec(
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels, * prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
* Textvorlagen und Auswahlwerten nicht vor. * Textvorlagen und Auswahlwerten nicht vor.
* *
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ...`, jedes Feld `N␟label␟maxLength␟decimal` bzw. * Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ... [␞ B]`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
* `C␟label␟option␝option␝...`. * `C␟label␟option␝option␝...`.
*/ */
object InputSpecCodec { object InputSpecCodec {
@@ -94,6 +102,11 @@ object InputSpecCodec {
private const val UNIT_SEPARATOR = '\u001F' private const val UNIT_SEPARATOR = '\u001F'
private const val GROUP_SEPARATOR = '\u001D' private const val GROUP_SEPARATOR = '\u001D'
/** Optionaler Abschluss-Datensatz "eigene Checkbox" (s. [InputSpec.requiresOwnCheckbox]).
* Ältere Decoder ignorieren unbekannte Datensätze, bereits gespeicherte Snapshots ohne
* diesen Datensatz werden mit `requiresOwnCheckbox = false` gelesen. */
private const val OWN_CHECKBOX_RECORD = "B"
fun encode(spec: InputSpec): String { fun encode(spec: InputSpec): String {
val records = mutableListOf(spec.pdfTemplate) val records = mutableListOf(spec.pdfTemplate)
for (field in spec.fields) { for (field in spec.fields) {
@@ -112,6 +125,7 @@ object InputSpecCodec {
).joinToString(UNIT_SEPARATOR.toString()) ).joinToString(UNIT_SEPARATOR.toString())
} }
} }
if (spec.requiresOwnCheckbox) records += OWN_CHECKBOX_RECORD
return records.joinToString(RECORD_SEPARATOR.toString()) return records.joinToString(RECORD_SEPARATOR.toString())
} }
@@ -150,6 +164,10 @@ object InputSpecCodec {
} }
} }
if (fields.isEmpty()) return null if (fields.isEmpty()) return null
return InputSpec(pdfTemplate = records.first(), fields = fields) return InputSpec(
pdfTemplate = records.first(),
fields = fields,
requiresOwnCheckbox = records.drop(1).any { it == OWN_CHECKBOX_RECORD },
)
} }
} }
@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.text.TextPaint import android.text.TextPaint
import de.medapp.wunddoku.domain.model.InputSpecCodec import de.medapp.wunddoku.domain.model.InputSpecCodec
@@ -99,6 +100,14 @@ class AndroidPdfDocumentExporter(
*/ */
private const val PHOTO_TARGET_DPI = 150 private const val PHOTO_TARGET_DPI = 150
/**
* Änderungswunsch: Bilder im Fotoanhang werden auf 50 % ihrer bisherigen Größe
* ausgegeben (Breite und Höhe je halbiert, Seitenverhältnis unverändert). Derselbe
* Faktor gilt für die Decode-Auflösung (s. [PHOTO_MAX_WIDTH_PX]), sodass die
* Bilddichte bei [PHOTO_TARGET_DPI] bleibt und das PDF zugleich deutlich kleiner wird.
*/
private const val PHOTO_SIZE_FACTOR = 0.5f
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe // Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese // Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal // Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
@@ -108,11 +117,11 @@ class AndroidPdfDocumentExporter(
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 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). */ /** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 536px bei 257,5pt/150dpi). */
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt() private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */ /** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 793px bei 381pt/150dpi). */
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt() private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
} }
@@ -209,10 +218,11 @@ class AndroidPdfDocumentExporter(
textSize = HEADER_INFO_TEXT_SIZE textSize = HEADER_INFO_TEXT_SIZE
color = Color.BLACK color = Color.BLACK
} }
// Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv.
val blockTitle = TextPaint().apply { val blockTitle = TextPaint().apply {
isAntiAlias = true isAntiAlias = true
textSize = BLOCK_TITLE_TEXT_SIZE textSize = BLOCK_TITLE_TEXT_SIZE
isFakeBoldText = true typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
color = Color.BLACK color = Color.BLACK
} }
val item = TextPaint().apply { val item = TextPaint().apply {
@@ -268,8 +278,15 @@ class AndroidPdfDocumentExporter(
cursor.drawDivider(spacingAfter = 12f) cursor.drawDivider(spacingAfter = 12f)
} }
/** Überschriften erscheinen im PDF immer mit abschließendem Doppelpunkt (kein doppelter
* Doppelpunkt, falls der Titel bereits mit einem endet). */
private fun headingText(title: String): String {
val trimmed = title.trimEnd()
return if (trimmed.endsWith(":")) trimmed else "$trimmed:"
}
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) { private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f) cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
for (item in block.items) { for (item in block.items) {
drawItem(cursor, item, paints) drawItem(cursor, item, paints)
@@ -289,8 +306,15 @@ class AndroidPdfDocumentExporter(
// vorgibt. // vorgibt.
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
if (decodedInputSpec != null) { if (decodedInputSpec != null) {
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"), angehakt aber ohne
// Auswahl: nur das Label ausgeben statt einer Vorlage mit "–"-Platzhaltern.
val itemText = if (decodedInputSpec.requiresOwnCheckbox && !decodedInputSpec.hasAnyValue(item.freeTextValue)) {
item.label
} else {
decodedInputSpec.formatForPdf(item.freeTextValue)
}
cursor.drawText( cursor.drawText(
text = decodedInputSpec.formatForPdf(item.freeTextValue), text = itemText,
paint = paints.item, paint = paints.item,
indent = ITEM_INDENT, indent = ITEM_INDENT,
spacingAfter = 3f, spacingAfter = 3f,
@@ -298,8 +322,10 @@ class AndroidPdfDocumentExporter(
return return
} }
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
val labelText = if (item.isFreeText) "${item.label}:" else item.label // folgt (z. B. "Granudacyn" ohne Text bleibt ohne hängenden Doppelpunkt), normale
// Optionen nie.
val labelText = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
cursor.drawText( cursor.drawText(
text = labelText, text = labelText,
@@ -334,7 +360,7 @@ class AndroidPdfDocumentExporter(
paints: ExportPaints, paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>, retainedSignatureBitmaps: MutableList<Bitmap>,
) { ) {
cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER) cursor.drawText(headingText("Unterschriften"), paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
for (signature in model.signatures) { for (signature in model.signatures) {
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps) drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
} }
@@ -430,7 +456,7 @@ class AndroidPdfDocumentExporter(
) { ) {
cursor.forceNewPage() cursor.forceNewPage()
if (index == 0) { if (index == 0) {
cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f) cursor.drawText(headingText("Fotoanhang"), paints.blockTitle, spacingAfter = 8f)
} }
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f) cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
@@ -445,7 +471,7 @@ class AndroidPdfDocumentExporter(
if (bitmap != null) { if (bitmap != null) {
retainedPhotoBitmaps.add(bitmap) retainedPhotoBitmaps.add(bitmap)
cursor.drawBitmapFitted(bitmap, paints.photo) cursor.drawBitmapFitted(bitmap, paints.photo, sizeFactor = PHOTO_SIZE_FACTOR)
} else { } else {
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B: // Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar // neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
@@ -107,17 +107,21 @@ internal class PdfPageCursor(
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest * darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
* hineinskaliert (kleiner, nie abgeschnitten). * hineinskaliert (kleiner, nie abgeschnitten).
*/ */
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) { fun drawBitmapFitted(bitmap: Bitmap, paint: Paint, sizeFactor: Float = 1f) {
startIfNeeded() startIfNeeded()
val availableWidth = contentWidth.toFloat() val availableWidth = contentWidth.toFloat()
val availableHeight = remainingHeight val availableHeight = remainingHeight
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) // [sizeFactor] < 1 (Änderungswunsch: Fotoanhang auf 50 %): das Bild wird zuerst wie
// gewohnt in den verfügbaren Platz eingepasst und dann um diesen Faktor verkleinert
// (Seitenverhältnis bleibt). Das verkleinerte Bild steht horizontal zentriert direkt
// unter der Bildunterschrift (oben ausgerichtet) statt mitten in der Restfläche.
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) * sizeFactor
val drawWidth = bitmap.width * scale val drawWidth = bitmap.width * scale
val drawHeight = bitmap.height * scale val drawHeight = bitmap.height * scale
val left = marginLeft + (availableWidth - drawWidth) / 2f val left = marginLeft + (availableWidth - drawWidth) / 2f
val top = cursorY + (availableHeight - drawHeight) / 2f val top = if (sizeFactor < 1f) cursorY else cursorY + (availableHeight - drawHeight) / 2f
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint) canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY = contentBottom() cursorY = contentBottom()
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.ui.documentation.edit package de.medapp.wunddoku.ui.documentation.edit
import android.content.ActivityNotFoundException
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -32,6 +33,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedIconButton
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.SnackbarHost
@@ -60,6 +63,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -74,6 +78,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -94,6 +99,7 @@ import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.PdfShareResult import de.medapp.wunddoku.pdf.PdfShareResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
import java.io.File
/** /**
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
@@ -155,6 +161,51 @@ fun DocumentationEditScreen(
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) } val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
val pdfSharer = remember(context) { AndroidPdfSharer(context) } val pdfSharer = remember(context) { AndroidPdfSharer(context) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Kamerabutton im Fotobereich (Änderungswunsch): nimmt mehrere Fotos hintereinander auf.
// Es wird die System-Kamera-App über ACTION_IMAGE_CAPTURE (TakePicture-Contract) genutzt -
// dafür ist KEINE CAMERA-Berechtigung nötig. Nach jeder erfolgreichen Aufnahme öffnet sich
// die Kamera automatisch erneut; Abbrechen/Zurück beendet die Serie. Die Aufnahme landet
// zunächst in einer temporären Cache-Datei (FileProvider-Pfad "camera/") und wird dann wie
// eine Picker-Auswahl in die Dokumentation importiert (Downsampling, EXIF, interne Ablage).
// Der Pfad der gerade laufenden Aufnahme ist rememberSaveable, damit er einen Wechsel der
// Konfiguration (Drehen) oder Prozesstod während der Kamera-App übersteht.
var pendingCameraFilePath by rememberSaveable { mutableStateOf<String?>(null) }
var cameraRelaunchRequested by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success: Boolean ->
val file = pendingCameraFilePath?.let(::File)
pendingCameraFilePath = null
if (file != null && success && file.length() > 0L) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
viewModel.onCameraPhotoCaptured(uri) { file.delete() }
// Nächste Aufnahme der Serie.
cameraRelaunchRequested = true
} else {
// Abgebrochen (oder leere Datei): Serie beendet, temporäre Datei aufräumen.
file?.delete()
}
}
fun startCamera() {
val captureDir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("capture_", ".jpg", captureDir)
pendingCameraFilePath = file.absolutePath
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
try {
cameraLauncher.launch(uri)
} catch (e: ActivityNotFoundException) {
pendingCameraFilePath = null
file.delete()
viewModel.onCameraUnavailable()
}
}
LaunchedEffect(cameraRelaunchRequested) {
if (cameraRelaunchRequested) {
cameraRelaunchRequested = false
startCamera()
}
}
LaunchedEffect(uiState.pdfExportMessage) { LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect val message = uiState.pdfExportMessage ?: return@LaunchedEffect
val exportedFile = uiState.lastExportedPdfFile val exportedFile = uiState.lastExportedPdfFile
@@ -281,6 +332,7 @@ fun DocumentationEditScreen(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
) )
}, },
onTakePhotosClick = ::startCamera,
onDeletePhotoRequested = viewModel::requestDeletePhoto, onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail, loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it }, onPhotoClick = { viewedPhoto = it },
@@ -440,6 +492,7 @@ private fun DocumentationEditContent(
photoImportError: String?, photoImportError: String?,
onDismissPhotoImportError: () -> Unit, onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit, onAddPhotosClick: () -> Unit,
onTakePhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onPhotoClick: (Photo) -> Unit, onPhotoClick: (Photo) -> Unit,
@@ -536,6 +589,10 @@ private fun DocumentationEditContent(
if (photoImportInProgress) { if (photoImportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} }
// Kamera: mehrere Aufnahmen hintereinander (s. startCamera).
OutlinedIconButton(onClick = onTakePhotosClick) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Fotos aufnehmen")
}
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) { OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
Icon(Icons.Filled.Add, contentDescription = null) Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
@@ -894,15 +951,32 @@ private fun StructuredInputRow(
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) } val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) }
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"): Felder nur bei angehakter
// Checkbox aktiv, das Häkchen setzt allein der Nutzer. Sonst bisheriges Verhalten.
val ownCheckbox = spec.requiresOwnCheckbox
val fieldsEnabled = !ownCheckbox || item.checked
fun updateValue(fieldIndex: Int, newValue: String) { fun updateValue(fieldIndex: Int, newValue: String) {
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue } val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
val encoded = spec.encodeValues(newValues) val encoded = spec.encodeValues(newValues)
onFreeTextChanged(item.checkItemStateId, encoded) onFreeTextChanged(item.checkItemStateId, encoded)
if (!ownCheckbox) {
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded)) onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
} }
}
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
if (ownCheckbox) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
)
Text(item.label)
}
} else {
Text(item.label, style = MaterialTheme.typography.bodyMedium) Text(item.label, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -930,6 +1004,7 @@ private fun StructuredInputRow(
}, },
label = { Text(field.label) }, label = { Text(field.label) },
singleLine = true, singleLine = true,
enabled = fieldsEnabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -937,6 +1012,7 @@ private fun StructuredInputRow(
is InputFieldDef.Choice -> ChoiceField( is InputFieldDef.Choice -> ChoiceField(
field = field, field = field,
selected = values.getOrElse(index) { "" }, selected = values.getOrElse(index) { "" },
enabled = fieldsEnabled,
onSelected = { updateValue(index, it) }, onSelected = { updateValue(index, it) },
) )
} }
@@ -953,11 +1029,12 @@ private fun StructuredInputRow(
private fun ChoiceField( private fun ChoiceField(
field: InputFieldDef.Choice, field: InputFieldDef.Choice,
selected: String, selected: String,
enabled: Boolean = true,
onSelected: (String) -> Unit, onSelected: (String) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = { expanded = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text(selected.ifBlank { field.label }, maxLines = 1) Text(selected.ifBlank { field.label }, maxLines = 1)
} }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@@ -26,7 +26,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File import java.io.File
import java.util.concurrent.atomic.AtomicInteger
data class DocumentationEditUiState( data class DocumentationEditUiState(
val documentation: Documentation? = null, val documentation: Documentation? = null,
@@ -117,6 +120,10 @@ class DocumentationEditViewModel(
private val woundCarerRepository: WoundCarerRepository, private val woundCarerRepository: WoundCarerRepository,
) : ViewModel() { ) : ViewModel() {
/** Serialisiert Fotoimporte (s. [importPhotos]) und zählt laufende Importe für den Ladeindikator. */
private val photoImportMutex = Mutex()
private val pendingPhotoImports = AtomicInteger(0)
private val _uiState = MutableStateFlow(DocumentationEditUiState()) private val _uiState = MutableStateFlow(DocumentationEditUiState())
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow() val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
@@ -224,9 +231,35 @@ class DocumentationEditViewModel(
* UI konsistent den tatsächlichen Datenbankstand zeigt. * UI konsistent den tatsächlichen Datenbankstand zeigt.
*/ */
fun onPhotosPicked(uris: List<Uri>) { fun onPhotosPicked(uris: List<Uri>) {
if (uris.isEmpty()) return importPhotos(uris, cleanup = {})
}
/**
* Übernimmt ein mit der Kamera aufgenommenes Foto (Kamerabutton im Fotobereich, s.
* DocumentationEditScreen). Läuft über denselben Importweg wie die Photo-Picker-Auswahl
* (Downsampling, EXIF-Korrektur, interne Ablage). [cleanup] löscht anschließend die
* temporäre Aufnahmedatei im Cache - unabhängig davon, ob der Import gelungen ist.
*/
fun onCameraPhotoCaptured(uri: Uri, cleanup: () -> Unit) {
importPhotos(listOf(uri), cleanup)
}
/**
* Gemeinsamer Importweg für Picker-Auswahl und Kameraaufnahmen. Importe laufen über einen
* [photoImportMutex] strikt nacheinander: Bei einer Kameraserie kann die nächste Aufnahme
* eintreffen, während die vorige noch importiert wird - ohne Sperre könnten zwei Importe
* dieselbe nächste orderIndex vergeben.
*/
private fun importPhotos(uris: List<Uri>, cleanup: () -> Unit) {
if (uris.isEmpty()) {
runCatching { cleanup() }
return
}
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) } _uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
pendingPhotoImports.incrementAndGet()
try {
photoImportMutex.withLock {
var failureCount = 0 var failureCount = 0
for (uri in uris) { for (uri in uris) {
try { try {
@@ -245,15 +278,26 @@ class DocumentationEditViewModel(
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
photoImportInProgress = false,
photoImportError = if (failureCount > 0) { photoImportError = if (failureCount > 0) {
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden." "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
} else { } else {
null state.photoImportError
}, },
) )
} }
} }
} finally {
runCatching { cleanup() }
if (pendingPhotoImports.decrementAndGet() == 0) {
_uiState.update { it.copy(photoImportInProgress = false) }
}
}
}
}
/** Meldet, dass die Kamera auf dem Gerät nicht gestartet werden konnte (keine Kamera-App). */
fun onCameraUnavailable() {
_uiState.update { it.copy(photoImportError = "Es konnte keine Kamera-App gestartet werden.") }
} }
fun dismissPhotoImportError() { fun dismissPhotoImportError() {
+4 -1
View File
@@ -3,11 +3,14 @@
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir) Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>- für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
intern (Abschnitt "Storage-Strategie" der Architektur, v3). intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
--> -->
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" /> <files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" /> <files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" /> <files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" /> <files-path name="signatures" path="signatures/" />
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
Dokumentation, danach sofort gelöscht. -->
<cache-path name="camera_captures" path="camera/" />
</paths> </paths>