Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34cd20786b | |||
| e9403801d6 | |||
| 18a7af800f | |||
| bf9fcf7b0b |
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
|
|||||||
unterstützt Fotos und digitale Unterschriften und erzeugt aus
|
unterstützt Fotos und digitale Unterschriften und erzeugt aus
|
||||||
abgeschlossenen Dokumentationen PDF-Dateien.
|
abgeschlossenen Dokumentationen PDF-Dateien.
|
||||||
|
|
||||||
> **Projektstatus:** beta1.1
|
> **Projektstatus:** beta1.2
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -398,8 +398,8 @@ werden kann.
|
|||||||
|
|
||||||
### Phase 6 -- Qualität und Stabilität
|
### Phase 6 -- Qualität und Stabilität
|
||||||
|
|
||||||
- [ ] Unit Tests
|
- [x] Unit Tests
|
||||||
- [ ] Room Tests
|
- [x] Room Tests
|
||||||
- [ ] UI Tests
|
- [ ] UI Tests
|
||||||
- [ ] Fehlerbehandlung
|
- [ ] Fehlerbehandlung
|
||||||
- [ ] Tablet-Optimierung
|
- [ ] Tablet-Optimierung
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -511,7 +502,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",
|
||||||
@@ -527,36 +520,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()
|
||||||
|
|||||||
+78
-1
@@ -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 }) {
|
||||||
|
|||||||
+47
-3
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user