diff --git a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt index 5551e82..80fc6b0 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt @@ -28,11 +28,11 @@ import de.medapp.wunddoku.domain.model.InputSpec * Abschnitt 2.3b. * * [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 { - 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, @@ -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", "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 = listOf("I", "II", "III", "IV") val blocks: List = listOf( BlockDefinition( @@ -60,15 +59,7 @@ object DocumentationCatalog { items = listOf( 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_decubitus", - label = "Decubitus", - order = 3, - inputSpec = InputSpec( - pdfTemplate = "Decubitus Grad {0}", - fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)), - ), - ), + CheckItemDefinition(id = "wundart_decubitus", label = "Decubitus", order = 3), 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_arteriosum", label = "Ulcus Cruris art.", order = 6), @@ -511,7 +502,9 @@ object DocumentationCatalog { 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_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( id = "empfohlene_wundversorgung_getraenkte_kompresse", label = "Getränkte Kompresse", @@ -527,36 +520,43 @@ object DocumentationCatalog { 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_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_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true), - CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 15), - CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16), + CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13), + CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true), + CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true), + 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( id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche", - label = "Wie oft pro Woche?", - order = 17, + label = "Wie oft pro Woche", + order = 19, inputSpec = InputSpec( pdfTemplate = "{0}x pro Woche", 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_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19), - CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20), - CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true), - CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true), - CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23), - CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24), - CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25), - CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26), - CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true), - CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28), - CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true), - CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, 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 = 21), + CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22), + CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true), + CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true), + CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25), + CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26), + CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27), + CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28), + CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29), + CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true), + CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31), + 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( id = "empfohlene_wundversorgung_hinweis_abklaerung", label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung", - order = 31, + order = 35, isStaticHint = true, ), ), diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/InputSpec.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/InputSpec.kt index c34277b..87133b3 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/InputSpec.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/InputSpec.kt @@ -46,6 +46,14 @@ sealed interface InputFieldDef { data class InputSpec( val pdfTemplate: String, val fields: List, + /** + * `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. */ @@ -86,7 +94,7 @@ data class InputSpec( * prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels, * 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␝...`. */ object InputSpecCodec { @@ -94,6 +102,11 @@ object InputSpecCodec { private const val UNIT_SEPARATOR = '\u001F' 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 { val records = mutableListOf(spec.pdfTemplate) for (field in spec.fields) { @@ -112,6 +125,7 @@ object InputSpecCodec { ).joinToString(UNIT_SEPARATOR.toString()) } } + if (spec.requiresOwnCheckbox) records += OWN_CHECKBOX_RECORD return records.joinToString(RECORD_SEPARATOR.toString()) } @@ -150,6 +164,10 @@ object InputSpecCodec { } } 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 }, + ) } } 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 8ec41bf..1c675a1 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Color import android.graphics.Paint +import android.graphics.Typeface import android.graphics.pdf.PdfDocument import android.text.TextPaint import de.medapp.wunddoku.domain.model.InputSpecCodec @@ -99,6 +100,14 @@ class AndroidPdfDocumentExporter( */ 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 // Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese // 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_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-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 * PHOTO_SIZE_FACTOR / 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() + /** 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 * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt() private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } @@ -209,10 +218,11 @@ class AndroidPdfDocumentExporter( textSize = HEADER_INFO_TEXT_SIZE color = Color.BLACK } + // Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv. val blockTitle = TextPaint().apply { isAntiAlias = true textSize = BLOCK_TITLE_TEXT_SIZE - isFakeBoldText = true + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC) color = Color.BLACK } val item = TextPaint().apply { @@ -268,8 +278,15 @@ class AndroidPdfDocumentExporter( 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) { - cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f) + cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f) for (item in block.items) { drawItem(cursor, item, paints) @@ -289,8 +306,15 @@ class AndroidPdfDocumentExporter( // vorgibt. val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else 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( - text = decodedInputSpec.formatForPdf(item.freeTextValue), + text = itemText, paint = paints.item, indent = ITEM_INDENT, spacingAfter = 3f, @@ -298,8 +322,10 @@ class AndroidPdfDocumentExporter( return } - // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. - val labelText = if (item.isFreeText) "${item.label}:" else item.label + // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text + // 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( text = labelText, @@ -334,7 +360,7 @@ class AndroidPdfDocumentExporter( paints: ExportPaints, retainedSignatureBitmaps: MutableList, ) { - 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) { drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps) } @@ -430,7 +456,7 @@ class AndroidPdfDocumentExporter( ) { cursor.forceNewPage() 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) @@ -445,7 +471,7 @@ class AndroidPdfDocumentExporter( if (bitmap != null) { retainedPhotoBitmaps.add(bitmap) - cursor.drawBitmapFitted(bitmap, paints.photo) + cursor.drawBitmapFitted(bitmap, paints.photo, sizeFactor = PHOTO_SIZE_FACTOR) } else { // Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B: // neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar 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 12b126a..b3c2cfd 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt @@ -107,17 +107,21 @@ internal class PdfPageCursor( * darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest * hineinskaliert (kleiner, nie abgeschnitten). */ - fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) { + fun drawBitmapFitted(bitmap: Bitmap, paint: Paint, sizeFactor: Float = 1f) { 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) + // [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 drawHeight = bitmap.height * scale 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) cursorY = contentBottom() 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 cbf76c9..aebba3f 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 @@ -1,5 +1,6 @@ package de.medapp.wunddoku.ui.documentation.edit +import android.content.ActivityNotFoundException import android.graphics.Bitmap import android.net.Uri 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.ExpandLess 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.Share import androidx.compose.material3.AlertDialog @@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -60,6 +63,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.unit.dp import androidx.compose.ui.window.Dialog +import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver 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.toUserMessage 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 @@ -155,6 +161,51 @@ fun DocumentationEditScreen( val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) } val pdfSharer = remember(context) { AndroidPdfSharer(context) } 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(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) { val message = uiState.pdfExportMessage ?: return@LaunchedEffect val exportedFile = uiState.lastExportedPdfFile @@ -281,6 +332,7 @@ fun DocumentationEditScreen( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) }, + onTakePhotosClick = ::startCamera, onDeletePhotoRequested = viewModel::requestDeletePhoto, loadThumbnail = viewModel::loadPhotoThumbnail, onPhotoClick = { viewedPhoto = it }, @@ -440,6 +492,7 @@ private fun DocumentationEditContent( photoImportError: String?, onDismissPhotoImportError: () -> Unit, onAddPhotosClick: () -> Unit, + onTakePhotosClick: () -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, onPhotoClick: (Photo) -> Unit, @@ -536,6 +589,10 @@ private fun DocumentationEditContent( if (photoImportInProgress) { 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) { Icon(Icons.Filled.Add, contentDescription = null) Spacer(Modifier.width(4.dp)) @@ -894,15 +951,32 @@ private fun StructuredInputRow( val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return 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) { val newValues = values.toMutableList().apply { this[fieldIndex] = newValue } val encoded = spec.encodeValues(newValues) onFreeTextChanged(item.checkItemStateId, encoded) - onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded)) + if (!ownCheckbox) { + onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded)) + } } Column(modifier = Modifier.fillMaxWidth()) { - Text(item.label, style = MaterialTheme.typography.bodyMedium) + 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) + } Spacer(Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -930,6 +1004,7 @@ private fun StructuredInputRow( }, label = { Text(field.label) }, singleLine = true, + enabled = fieldsEnabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), ) @@ -937,6 +1012,7 @@ private fun StructuredInputRow( is InputFieldDef.Choice -> ChoiceField( field = field, selected = values.getOrElse(index) { "" }, + enabled = fieldsEnabled, onSelected = { updateValue(index, it) }, ) } @@ -953,11 +1029,12 @@ private fun StructuredInputRow( private fun ChoiceField( field: InputFieldDef.Choice, selected: String, + enabled: Boolean = true, onSelected: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } 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) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { 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 91bf8ca..aa48f8d 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 @@ -26,7 +26,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File +import java.util.concurrent.atomic.AtomicInteger data class DocumentationEditUiState( val documentation: Documentation? = null, @@ -117,6 +120,10 @@ class DocumentationEditViewModel( private val woundCarerRepository: WoundCarerRepository, ) : 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()) val uiState: StateFlow = _uiState.asStateFlow() @@ -224,38 +231,75 @@ class DocumentationEditViewModel( * UI konsistent den tatsächlichen Datenbankstand zeigt. */ fun onPhotosPicked(uris: List) { - 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, cleanup: () -> Unit) { + if (uris.isEmpty()) { + runCatching { cleanup() } + return + } viewModelScope.launch { _uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) } - var failureCount = 0 - for (uri in uris) { - try { - repository.addPhoto(documentationId, uri) - } catch (e: Exception) { - failureCount++ - // Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/ - // Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose. - android.util.Log.w( - "DocumentationEdit", - "Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}", - ) + pendingPhotoImports.incrementAndGet() + try { + photoImportMutex.withLock { + var failureCount = 0 + for (uri in uris) { + try { + repository.addPhoto(documentationId, uri) + } catch (e: Exception) { + failureCount++ + // Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/ + // Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose. + android.util.Log.w( + "DocumentationEdit", + "Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}", + ) + } + } + val refreshed = repository.getFullDocumentation(documentationId) + _uiState.update { state -> + state.copy( + documentation = refreshed ?: state.documentation, + photoImportError = if (failureCount > 0) { + "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden." + } else { + state.photoImportError + }, + ) + } + } + } finally { + runCatching { cleanup() } + if (pendingPhotoImports.decrementAndGet() == 0) { + _uiState.update { it.copy(photoImportInProgress = false) } } } - val refreshed = repository.getFullDocumentation(documentationId) - _uiState.update { state -> - state.copy( - documentation = refreshed ?: state.documentation, - photoImportInProgress = false, - photoImportError = if (failureCount > 0) { - "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden." - } else { - null - }, - ) - } } } + /** 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() { _uiState.update { it.copy(photoImportError = null) } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index c5bfa87..77d0b08 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,11 +3,14 @@ Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir) für die Freigabe über den FileProvider frei. Es gibt bewusst keinen - oder -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). --> + +