Compare commits

...

5 Commits

Author SHA1 Message Date
niklas.ortmann 075905c6e0 Update DocumentationCatalog.kt 2026-09-16 10:08:29 +02:00
niklas.ortmann 7948d2c3c6 Box-Inhalte
(müssen mit Kunden besprochen werden)
2026-09-09 08:44:07 +02:00
niklas.ortmann 2ddc1d960a Merge pull request 'Kundenwuensche' (#6) from dev-klock into dev
Reviewed-on: #6
2026-09-07 11:42:06 +02:00
niklas.ortmann 32d23f5d52 Kundenwuensche
Nur angehakte Checkboxen im PDF
Fotos in Originalgröße per Antippen
Freitext zuerst in jedem Block
Akkordeon-Umbau (angefangen)
2026-09-07 09:46:22 +02:00
niklas.ortmann cda0b4d3fe Merge pull request 'PDF/MEDIA' (#5) from dev-PDF into dev
Reviewed-on: #5
2026-09-06 21:20:29 +02:00
16 changed files with 1442 additions and 164 deletions
@@ -0,0 +1,629 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "12448571f5ffcf00398fa1d8260c9b2b",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isStaticHint",
"columnName": "isStaticHint",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12448571f5ffcf00398fa1d8260c9b2b')"
]
}
}
@@ -44,6 +44,10 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers` * Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein * (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
* additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen. * additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen.
*
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -55,7 +59,7 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
SignatureEntity::class, SignatureEntity::class,
WoundCarerEntity::class, WoundCarerEntity::class,
], ],
version = 4, version = 5,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DocumentationStatusConverter::class) @TypeConverters(DocumentationStatusConverter::class)
@@ -83,7 +87,7 @@ abstract class AppDatabase : RoomDatabase() {
DATABASE_NAME, DATABASE_NAME,
) )
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar. // Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
.addMigrations(MIGRATION_2_3, MIGRATION_3_4) .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -111,3 +111,23 @@ val MIGRATION_3_4 = object : Migration(3, 4) {
) )
} }
} }
/**
* Migration von Schema-Version 4 auf 5 (finale fachliche Katalog-Inhalte: neuer Content-Typ
* "statischer Hinweistext", z. B. "+ bei Bedarf" bei "Empfohlene Wundversorgung").
*
* Rein additiv - eine einzelne neue, NOT NULL-Spalte mit Default `false` (0) auf einer
* bestehenden Tabelle. Anders als bei der Signaturen-Migration (2->3, dort war ein
* NULL->NOT-NULL-Wechsel auf einer bereits befüllten Spalte nötig) ist hier ein einfaches
* `ALTER TABLE ... ADD COLUMN` mit Default-Wert ausreichend und von SQLite direkt unterstützt -
* kein Tabellenneubau nötig. Bestehende Zeilen erhalten automatisch `isStaticHint = 0`
* (entspricht ihrem bisherigen, unveränderten Verhalten als normale Checkbox- bzw.
* Freitext-Zeile).
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `check_item_states` ADD COLUMN `isStaticHint` INTEGER NOT NULL DEFAULT 0"
)
}
}
@@ -15,6 +15,18 @@ import androidx.room.PrimaryKey
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des * zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am * Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
* Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer. * Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer.
* Derselbe Mechanismus wird jetzt auch für vom KATALOG selbst definierte Freitext-Checkboxen
* verwendet (z. B. "Wundgrund"/"Erscheinung" oder die vier Empfänger-Freitexte bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
*
* [isStaticHint]: NEU. Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B.
* "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
* fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel
* im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
* "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt.
*/ */
@Entity( @Entity(
tableName = "check_item_states", tableName = "check_item_states",
@@ -40,4 +52,5 @@ data class CheckItemStateEntity(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
) )
@@ -71,6 +71,7 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
order = itemOrder, order = itemOrder,
checked = checked, checked = checked,
isFreeText = isFreeText, isFreeText = isFreeText,
isStaticHint = isStaticHint,
freeTextValue = freeTextValue, freeTextValue = freeTextValue,
) )
@@ -185,6 +185,7 @@ class DocumentationRepository(
itemOrder = item.itemOrder, itemOrder = item.itemOrder,
checked = item.checked, checked = item.checked,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
isStaticHint = item.isStaticHint,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
) )
} }
@@ -292,6 +293,19 @@ class DocumentationRepository(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
/**
* Lädt ein Foto in Originalgröße für die Vollbildansicht beim Antippen einer Foto-Kachel
* (Änderungswunsch). Verwendet bewusst [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]
* statt eines unskalierten Rohzugriffs: Fotos werden bereits beim Import auf eine sinnvolle
* Maximalauflösung begrenzt (s. PhotoStorage-Importlogik), ein zusätzliches Herunterrechnen
* auf höchstens die tatsächliche Displaygröße ([maxWidthPx]/[maxHeightPx], vom Aufrufer aus
* der Bildschirmauflösung ermittelt) vermeidet unnötig hohen Speicherbedarf, ohne das Bild
* sichtbar zu verkleinern - der Nutzer sieht es exakt so groß wie der Bildschirm es ohnehin
* darstellen könnte.
*/
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? =
photoStorage.decodeFitted(documentationId, fileName, maxWidthPx, maxHeightPx)
/** /**
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates * Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein * Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
@@ -363,9 +377,30 @@ class DocumentationRepository(
/** /**
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro * Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
* Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option * Katalog-Eintrag PLUS eine zusätzliche, automatisch angehängte generische Freitext-Option
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL * (isFreeText = true, Label "Freitext") - siehe DocumentationCatalog.FREE_TEXT_LABEL und
* und BlockDefinition.freeTextDefinitionId. * BlockDefinition.freeTextDefinitionId. Diese generische Option wird WEITERHIN für JEDEN
* Block angehängt, unabhängig davon, ob der Block selbst bereits eigene, fachlich benannte
* `isFreeText`-Einträge definiert (z. B. "Wundgrund + Erscheinung" oder "Kopie
* nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine
* zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet.
*
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText` und
* `isStaticHint` steuern direkt das Verhalten der jeweiligen Zeile (normale Checkbox,
* eigene benannte Freitext-Checkbox, oder nicht editierbarer Hinweistext) - keine
* block-spezifische Sonderlogik nötig, die Katalogdefinition allein entscheidet.
*
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
* "nur angehakte Elemente"-Regel im PDF-Export automatisch auch für sie, ohne dass diese
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
*
* Die generische Freitext-Option erscheint als ERSTES Element im Block (itemOrder = 0,
* niedriger als jeder Katalog-Eintrag, dessen `order` bei 1 beginnt). Betrifft ausschließlich
* NEU erstellte Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen
* behalten ihre historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s.
* Klassendokumentation zu titleSnapshot/labelSnapshot).
*/ */
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) { for (blockDef in DocumentationCatalog.blocks) {
@@ -383,29 +418,34 @@ class DocumentationRepository(
) )
) )
val normalItems = blockDef.items.map { itemDef -> val catalogItems = blockDef.items.map { itemDef ->
CheckItemStateEntity( CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = itemDef.id, checkItemDefinitionId = itemDef.id,
labelSnapshot = itemDef.label, labelSnapshot = itemDef.label,
itemOrder = itemDef.order, itemOrder = itemDef.order,
checked = false, // Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
isFreeText = false, // anderen Einträge (Checkbox oder eigene Freitext-Checkbox) starten
// unangehakt/leer, wie bisher.
checked = itemDef.isStaticHint,
isFreeText = itemDef.isFreeText,
isStaticHint = itemDef.isStaticHint,
freeTextValue = "", freeTextValue = "",
) )
} }
val freeTextItem = CheckItemStateEntity( val genericFreeTextItem = CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId, checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = blockDef.items.size + 1, itemOrder = 0,
checked = false, checked = false,
isFreeText = true, isFreeText = true,
isStaticHint = false,
freeTextValue = "", freeTextValue = "",
) )
checkItemStateDao.insertAll(normalItems + freeTextItem) checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
} }
} }
} }
@@ -3,10 +3,30 @@ package de.medapp.wunddoku.domain.catalog
/** /**
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen. * Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
* *
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter * Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte"). * handschriftlich vorgegebene 22-Punkte-Reihenfolge, ergänzt/verfeinert nach Abgleich mit dem
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser * ursprünglichen Papierformular ("Wunddokumentation" von Harald Klock) - dabei wurden:
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden. *
* - mehrere Abschnitte, die im Chat/der handschriftlichen Liste nur als Freitext geführt waren,
* auf dem Papierbogen aber eine differenzierte Checkbox-Struktur zeigen, entsprechend
* erweitert (Wundgrund, Belag, Wundrand, Exsudat, Größe, Fistel/Tasche, Lokalisation,
* Empfohlene Wundversorgung),
* - zwei auf dem Papierbogen vorhandene, aber in der handschriftlichen Liste fehlende
* Abschnitte wieder aufgenommen ("Weitere Überlegung" - vermutlich versehentlich in der
* handschriftlichen Liste ausgelassen; "Nächster Termin" - auf dem Bogen als eigenes Feld
* vorhanden),
* - "Verlauf" bewusst NICHT um die alten Papier-Checkboxen erweitert (explizit vom Kunden
* bestätigt: bleibt reiner Freitext, die alte Checkbox-Auswahl wurde bewusst verworfen),
* - "Wundumgebung" und "Auswirkungen auf das alltägliche Leben" um die Papier-Checkbox-Listen
* ergänzt, jeweils MIT vorangestelltem freiem Textfeld (die ohnehin an jedem Block
* automatisch vorhandene generische Freitext-Option deckt das ab, s. [FREE_TEXT_LABEL]),
* - "Wundart" um die Papier-Checkbox "nicht bekannt/diagnostiziert" ergänzt (zusätzlich zum
* Freitext - NICHT Teil der früher verworfenen Wundarten-Liste, sondern ein eigenständiges
* Status-Flag).
*
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
* Inhalte (Wundart-Typenliste, Verlauf-Checkboxen, "Schmerzempfinden 1-10") sind weiterhin
* bewusst NICHT enthalten.
* *
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich * Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument * nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
@@ -16,51 +36,370 @@ package de.medapp.wunddoku.domain.catalog
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke * Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie * oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die * Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). * historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). Auf 3 erhöht mit dem
* Abgleich gegen das ursprüngliche Papierformular.
*/ */
object DocumentationCatalog { object DocumentationCatalog {
const val CATALOG_VERSION: Int = 1 const val CATALOG_VERSION: Int = 3
/** /**
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch: * Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral * "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral
* hier definiert, damit Repository (Erstellung/Fortsetzen) und ggf. der spätere PDF-Export * hier definiert, damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe
* dieselbe Bezeichnung verwenden. * Bezeichnung verwenden. Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die
* fachlich "nur Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
*/ */
const val FREE_TEXT_LABEL: String = "Freitext" const val FREE_TEXT_LABEL: String = "Freitext"
val blocks: List<BlockDefinition> = listOf( val blocks: List<BlockDefinition> = listOf(
BlockDefinition( BlockDefinition(
id = "block_01", id = "wundart",
title = "Block 1", title = "Wundart",
order = 1, order = 1,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1), CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "nicht bekannt/diagnostiziert", order = 1),
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_02", id = "lokalisation",
title = "Block 2", title = "Lokalisation",
order = 2, order = 2,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1), CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_03", id = "groesse",
title = "Block 3", title = "Größe",
order = 3, order = 3,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1), CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "multiple Wunden mit max. (cm)", order = 1, isFreeText = true),
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2), CheckItemDefinition(id = "groesse_wunde", label = "Wunde", order = 2),
CheckItemDefinition(id = "groesse_areal", label = "Areal", order = 3),
CheckItemDefinition(id = "groesse_masse", label = "Maße (Länge x Breite, ca. cm)", order = 4, isFreeText = true),
CheckItemDefinition(id = "groesse_vertikale_tiefe", label = "Vertikale Tiefe (ca. cm)", order = 5, isFreeText = true),
), ),
), ),
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen BlockDefinition(
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch. id = "verlauf",
title = "Verlauf",
order = 4,
items = emptyList(),
),
BlockDefinition(
id = "exsudat",
title = "Exsudat",
order = 5,
items = listOf(
CheckItemDefinition(id = "exsudat_kein", label = "Kein", order = 1),
CheckItemDefinition(id = "exsudat_wenig", label = "Wenig", order = 2),
CheckItemDefinition(id = "exsudat_maessig", label = "Mäßig", order = 3),
CheckItemDefinition(id = "exsudat_viel", label = "Viel", order = 4),
CheckItemDefinition(id = "exsudat_uebermaessig", label = "Übermäßig", order = 5),
CheckItemDefinition(id = "exsudat_waessrig", label = "Wässrig", order = 6),
CheckItemDefinition(id = "exsudat_zaehfluessig", label = "Zähflüssig", order = 7),
CheckItemDefinition(id = "exsudat_eitrig", label = "Eitrig", order = 8),
CheckItemDefinition(id = "exsudat_blutig", label = "Blutig", order = 9),
CheckItemDefinition(id = "exsudat_klar", label = "Klar", order = 10),
CheckItemDefinition(id = "exsudat_gelblich", label = "Gelblich", order = 11),
CheckItemDefinition(id = "exsudat_gruenlich", label = "Grünlich", order = 12),
CheckItemDefinition(id = "exsudat_braeunlich", label = "Bräunlich", order = 13),
CheckItemDefinition(id = "exsudat_roetlich", label = "Rötlich", order = 14),
CheckItemDefinition(id = "exsudat_weisslich", label = "Weißlich", order = 15),
),
),
BlockDefinition(
id = "wundgrund_erscheinung",
title = "Wundgrund + Erscheinung",
order = 6,
items = listOf(
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 1),
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 2),
CheckItemDefinition(id = "wundgrund_knospig", label = "Knospig", order = 3),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 4),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 5),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 6),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 7),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 8),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 9),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 10),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 11),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 12),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 13),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 14),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 15, isFreeText = true),
),
),
BlockDefinition(
id = "belag",
title = "Belag",
order = 7,
items = listOf(
CheckItemDefinition(id = "belag_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "belag_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "belag_fibrin", label = "Fibrin", order = 3),
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 7),
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 8),
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 9),
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 10),
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 11),
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 12),
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 13),
),
),
BlockDefinition(
id = "wundrand",
title = "Wundrand",
order = 8,
items = listOf(
CheckItemDefinition(id = "wundrand_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundrand_partiell_unterschiedlich", label = "Partiell unterschiedlich", order = 2),
CheckItemDefinition(id = "wundrand_rosarot", label = "Rosarot", order = 3),
CheckItemDefinition(id = "wundrand_rot", label = "Rot", order = 4),
CheckItemDefinition(id = "wundrand_blaeulich", label = "Bläulich", order = 5),
CheckItemDefinition(id = "wundrand_blass", label = "Blass", order = 6),
CheckItemDefinition(id = "wundrand_blutig", label = "Blutig", order = 7),
CheckItemDefinition(id = "wundrand_mit_kruste_belegt", label = "Mit Kruste belegt", order = 8),
CheckItemDefinition(id = "wundrand_mazeriert", label = "Mazeriert", order = 9),
CheckItemDefinition(id = "wundrand_unterminiert", label = "Unterminiert", order = 10),
CheckItemDefinition(id = "wundrand_trocken", label = "Trocken", order = 11),
CheckItemDefinition(id = "wundrand_glatt", label = "Glatt", order = 12),
CheckItemDefinition(id = "wundrand_flach_einlaufend", label = "Flach einlaufend", order = 13),
CheckItemDefinition(id = "wundrand_wallartig", label = "Wallartig", order = 14),
CheckItemDefinition(id = "wundrand_wulstig", label = "Wulstig", order = 15),
CheckItemDefinition(id = "wundrand_zerklueftet", label = "Zerklüftet", order = 16),
CheckItemDefinition(id = "wundrand_ueberhaengende_strukturen_lefzen", label = "Überhängende Strukturen/Lefzen", order = 17),
),
),
BlockDefinition(
id = "wundumgebung",
title = "Wundumgebung",
order = 9,
items = listOf(
CheckItemDefinition(id = "wundumgebung_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundumgebung_oedem", label = "Ödem", order = 2),
CheckItemDefinition(id = "wundumgebung_ekzem", label = "Ekzem", order = 3),
CheckItemDefinition(id = "wundumgebung_schuppig", label = "Schuppig", order = 4),
CheckItemDefinition(id = "wundumgebung_narbengewebe", label = "Narbengewebe", order = 5),
CheckItemDefinition(id = "wundumgebung_hornhaut_verhaertet", label = "Hornhaut/verhärtet", order = 6),
CheckItemDefinition(id = "wundumgebung_geroetet", label = "Gerötet", order = 7),
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 11),
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 12),
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 13),
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 14),
),
),
BlockDefinition(
id = "fistel_tasche",
title = "Fistel / Tasche",
order = 10,
items = listOf(
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
CheckItemDefinition(id = "fistel_tasche_von_bis_uhr", label = "Von ... bis ... Uhr", order = 4, isFreeText = true),
CheckItemDefinition(id = "fistel_tasche_tiefe", label = "Tiefe (ca. cm)", order = 5, isFreeText = true),
),
),
BlockDefinition(
id = "entzuendungszeichen",
title = "Entzündungszeichen",
order = 11,
items = listOf(
CheckItemDefinition(id = "entzuendungszeichen_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "entzuendungszeichen_temperatur_erhoeht", label = "Temperatur erhöht", order = 2),
CheckItemDefinition(id = "entzuendungszeichen_schwellung", label = "Schwellung", order = 3),
CheckItemDefinition(id = "entzuendungszeichen_roetung", label = "Rötung", order = 4),
CheckItemDefinition(id = "entzuendungszeichen_funktionsstoerung", label = "Funktionsstörung", order = 5),
CheckItemDefinition(id = "entzuendungszeichen_schmerz", label = "Schmerz", order = 6),
),
),
BlockDefinition(
id = "geruch",
title = "Geruch",
order = 12,
items = listOf(
CheckItemDefinition(id = "geruch_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "geruch_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "geruch_gering", label = "Gering", order = 3),
CheckItemDefinition(id = "geruch_deutlich_wahrnehmbar", label = "Deutlich wahrnehmbar", order = 4),
),
),
BlockDefinition(
id = "schmerz",
title = "Schmerz",
order = 13,
items = listOf(
CheckItemDefinition(id = "schmerz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "schmerz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "schmerz_immer", label = "Immer", order = 3),
CheckItemDefinition(id = "schmerz_zeitweise", label = "Zeitweise", order = 4),
CheckItemDefinition(id = "schmerz_bei_verbandwechsel", label = "Bei Verbandwechsel", order = 5),
CheckItemDefinition(id = "schmerz_vorwiegend_nachts", label = "Vorwiegend nachts", order = 6),
CheckItemDefinition(id = "schmerz_wunde", label = "Wunde", order = 7),
CheckItemDefinition(id = "schmerz_umgebung", label = "Umgebung", order = 8),
),
),
BlockDefinition(
id = "juckreiz",
title = "Juckreiz",
order = 14,
items = listOf(
CheckItemDefinition(id = "juckreiz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "juckreiz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "juckreiz_wunde", label = "Wunde", order = 3),
CheckItemDefinition(id = "juckreiz_umgebung", label = "Umgebung", order = 4),
CheckItemDefinition(id = "juckreiz_immer_oft", label = "Immer / oft", order = 5),
CheckItemDefinition(id = "juckreiz_zeitweise", label = "Zeitweise", order = 6),
),
),
BlockDefinition(
id = "vitalwerte",
title = "Vitalwerte",
order = 15,
items = emptyList(),
),
BlockDefinition(
id = "auswirkungen_alltag",
title = "Auswirkungen auf das alltägliche Leben",
order = 16,
items = listOf(
CheckItemDefinition(id = "auswirkungen_alltag_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "auswirkungen_alltag_nicht_ermittelbar", label = "Nicht ermittelbar", order = 3),
),
),
BlockDefinition(
id = "bekannte_allergie",
title = "Bekannte Allergie",
order = 17,
items = emptyList(),
),
BlockDefinition(
id = "beratung_erfolgte",
title = "Beratung erfolgte",
order = 18,
items = listOf(
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
CheckItemDefinition(id = "beratung_ernaehrung", label = "Ernährung", order = 3),
CheckItemDefinition(id = "beratung_lagerung_druckentlastung", label = "Lagerung / Druckentlastung", order = 4),
CheckItemDefinition(id = "beratung_kleidung_schuhe", label = "Kleidung / Schuhe", order = 5),
CheckItemDefinition(id = "beratung_hautpflege", label = "Hautpflege", order = 6),
),
),
BlockDefinition(
id = "weitere_ueberlegung",
title = "Weitere Überlegung",
order = 19,
items = listOf(
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
CheckItemDefinition(id = "weitere_ueberlegung_wundabstrich", label = "Wundabstrich", order = 3),
CheckItemDefinition(id = "weitere_ueberlegung_angepasste_schmerztherapie", label = "Angepasste Schmerztherapie", order = 4),
CheckItemDefinition(id = "weitere_ueberlegung_anpassung_wundversorgung", label = "Anpassung der Wundversorgung", order = 5),
CheckItemDefinition(id = "weitere_ueberlegung_facharztliches_konsil", label = "Fachärztliches Konsil", order = 6),
CheckItemDefinition(id = "weitere_ueberlegung_ernaehrungsberatung", label = "Ernährungsberatung", order = 7),
CheckItemDefinition(id = "weitere_ueberlegung_pflegeberatung", label = "Pflegeberatung", order = 8),
CheckItemDefinition(id = "weitere_ueberlegung_hilfsmittel", label = "Hilfsmittel", order = 9),
CheckItemDefinition(id = "weitere_ueberlegung_bewegung_lagerungsplan", label = "Bewegung / Lagerungsplan", order = 10),
CheckItemDefinition(id = "weitere_ueberlegung_verbandwechsel_pflegedienst", label = "Verbandwechsel durch Pflegefachkräfte / Pflegedienst", order = 11),
CheckItemDefinition(
id = "weitere_ueberlegung_aerztliche_abklaerung_dringend",
label = "Ärztliche Abklärung zeitnah/dringend erforderlich / erbeten, Gefahr von bleibenden körperlichen Schäden / lebensbedrohlichen Prozessen",
order = 12,
),
),
),
BlockDefinition(
id = "sonstiges",
title = "Sonstiges",
order = 20,
items = emptyList(),
),
BlockDefinition(
id = "empfohlene_wundversorgung",
title = "Empfohlene Wundversorgung",
order = 21,
items = listOf(
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true),
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),
CheckItemDefinition(id = "empfohlene_wundversorgung_getraenkte_kompresse", label = "Getränkte Kompresse für ca. ... Min. auflegen", order = 6, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9),
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_secura", label = "Secura", order = 15),
CheckItemDefinition(id = "empfohlene_wundversorgung_cavilon", label = "Cavilon", order = 16),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 17, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 18),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 20, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 21),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 22),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband", label = "Kompressionsverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 27, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 28, isStaticHint = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 29,
isStaticHint = true,
),
),
),
BlockDefinition(
id = "ziel",
title = "Ziel",
order = 22,
items = listOf(
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
CheckItemDefinition(id = "ziel_abpolsterung", label = "Abpolsterung", order = 3),
CheckItemDefinition(id = "ziel_schmerzreduktion", label = "Schmerzreduktion", order = 4),
CheckItemDefinition(id = "ziel_angepasstes_exsudatmanagement", label = "Angepasstes Exsudatmanagement", order = 5),
CheckItemDefinition(id = "ziel_stabile_wundverhaeltnisse", label = "Stabile Wundverhältnisse", order = 6),
CheckItemDefinition(id = "ziel_abheilung", label = "Abheilung", order = 7),
CheckItemDefinition(id = "ziel_geruchsreduzierung", label = "Geruchsreduzierung", order = 8),
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
),
),
BlockDefinition(
id = "kopie_nachrichtlich_an",
title = "Kopie nachrichtlich an",
order = 23,
items = listOf(
CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_pflege", label = "Pflege", order = 2, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_patient", label = "Patient", order = 3, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_sonstige", label = "Sonstige", order = 4, isFreeText = true),
),
),
BlockDefinition(
id = "naechster_termin",
title = "Nächster Termin",
order = 24,
items = emptyList(),
),
) )
fun findBlock(blockDefinitionId: String): BlockDefinition? = fun findBlock(blockDefinitionId: String): BlockDefinition? =
@@ -74,16 +413,39 @@ data class BlockDefinition(
val items: List<CheckItemDefinition>, val items: List<CheckItemDefinition>,
) { ) {
/** /**
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte Freitext-Option * Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte, generische
* dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein Bestandteil von [items], * Freitext-Option dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein
* da die Freitext-Option kein fachlicher Katalog-Eintrag ist, sondern strukturell zu jedem * Bestandteil von [items], da diese generische Freitext-Option kein fachlicher
* Block gehört. * Katalog-Eintrag ist, sondern strukturell zu JEDEM Block gehört - unabhängig davon, ob
* der Block selbst bereits eigene `isFreeText`-Einträge in [items] definiert.
*/ */
val freeTextDefinitionId: String get() = "${id}_freetext" val freeTextDefinitionId: String get() = "${id}_freetext"
} }
/**
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
*
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false).
*
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch an JEDEN Block angehängten
* generischen Freitext-Option (s. DocumentationRepository.insertBlocksFromCatalog), nur mit
* eigenem Label statt "Freitext". Ein Block kann beliebig viele solcher Einträge definieren.
*
* [isStaticHint] = true: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" oder eine
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
* Text selbst, keine Checkbox-Beschriftung.
*
* [isFreeText] und [isStaticHint] schließen sich fachlich gegenseitig aus (ein Eintrag ist
* entweder normale Checkbox, Freitext-Checkbox oder Hinweistext) - es gibt aber bewusst keine
* Laufzeitprüfung dafür, um die Katalogdefinition einfach zu halten; beide gleichzeitig auf
* `true` zu setzen ergibt schlicht keinen sinnvollen Katalogeintrag.
*/
data class CheckItemDefinition( data class CheckItemDefinition(
val id: String, val id: String,
val label: String, val label: String,
val order: Int, val order: Int,
val isFreeText: Boolean = false,
val isStaticHint: Boolean = false,
) )
@@ -9,12 +9,17 @@ package de.medapp.wunddoku.domain.model
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt * historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
* dargestellt werden. * dargestellt werden.
* *
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am * [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT als separates Feld am
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block * Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt. Jeder Block
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`, * bekommt automatisch genau ein CheckItem mit `isFreeText = true`; zusätzlich kann der Katalog
* ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren * selbst beliebig viele weitere `isFreeText = true`-Optionen mit eigenem, fachlichem Label
* PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu * definieren (z. B. "Wundgrund", "Erscheinung", oder die vier Empfänger bei "Kopie
* unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen. * nachrichtlich an") - technisch identisch, nur mit eigenem Label statt des generischen
* "Freitext". Ist [isFreeText] `false`, ist [freeTextValue] stets leer und ohne Bedeutung.
*
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
* Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity-
* Kommentar), zählt aber NICHT als Bearbeitung (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]).
*/ */
data class CheckItem( data class CheckItem(
val checkItemStateId: String, val checkItemStateId: String,
@@ -24,4 +29,5 @@ data class CheckItem(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
) )
@@ -23,8 +23,15 @@ object BlockEditedPolicy {
* nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt * nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt
* ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon * ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon
* Text eingegeben wurde. * Text eingegeben wurde.
*
* EINZIGE Ausnahme: Hinweistext-Zeilen (CheckItem.isStaticHint = true) werden hierbei
* IGNORIERT, obwohl ihr `checked` technisch immer `true` ist (s. Entity-Kommentar zu
* isStaticHint) - ein reiner, nicht editierbarer Hinweistext ist keine Nutzereingabe und
* darf einen Block nicht allein dadurch als "bearbeitet" erscheinen lassen (z. B.
* "Empfohlene Wundversorgung" enthält zwei feste Hinweiszeilen, soll aber nur dann als
* bearbeitet gelten, wenn tatsächlich eine Empfehlung eingetragen wurde).
*/ */
fun isEdited(block: DocumentationBlock): Boolean { fun isEdited(block: DocumentationBlock): Boolean {
return block.items.any { it.checked } return block.items.any { it.checked && !it.isStaticHint }
} }
} }
@@ -2,7 +2,6 @@ package de.medapp.wunddoku.pdf
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
@@ -84,10 +83,9 @@ class AndroidPdfDocumentExporter(
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
// Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text. // Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
private const val CHECKBOX_SIZE = 10f // der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
private const val CHECKBOX_GAP = 6f private const val ITEM_INDENT = 10f
private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP
/** /**
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und * Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
@@ -244,21 +242,6 @@ class AndroidPdfDocumentExporter(
color = Color.DKGRAY color = Color.DKGRAY
} }
// Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit.
val checkboxOutline = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 1.2f
color = Color.BLACK
}
val checkboxMark = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 1.5f
strokeCap = Paint.Cap.ROUND
color = Color.BLACK
}
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für /** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */ * weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
val photo = Paint().apply { val photo = Paint().apply {
@@ -294,24 +277,23 @@ class AndroidPdfDocumentExporter(
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f) cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
} }
/** Änderungswunsch: KEIN Checkbox-Symbol mehr im PDF. Da [PdfExportBlock.items] bereits nur
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) { private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
val labelText = if (item.isFreeText) "${item.label}:" else item.label val labelText = if (item.isFreeText) "${item.label}:" else item.label
val checked = item.checked
cursor.drawText( cursor.drawText(
text = labelText, text = labelText,
paint = paints.item, paint = paints.item,
indent = CHECKBOX_INDENT, indent = ITEM_INDENT,
spacingAfter = 3f, spacingAfter = 3f,
leadingMark = { canvas, left, top, bottom ->
drawCheckboxGlyph(canvas, left, top, bottom, checked, paints)
},
) )
// Freitext nur zeichnen, wenn die Option angehakt UND tatsächlich Text vorhanden ist - // Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
// ein angehakter, aber leerer Freitext erzeugt keinen leeren Bereich im PDF. // leerer Freitext erzeugt keinen leeren Bereich im PDF.
if (item.isFreeText && checked && item.freeTextValue.isNotBlank()) { if (item.isFreeText && item.freeTextValue.isNotBlank()) {
cursor.drawText( cursor.drawText(
text = item.freeTextValue, text = item.freeTextValue,
paint = paints.freeText, paint = paints.freeText,
@@ -321,33 +303,6 @@ class AndroidPdfDocumentExporter(
} }
} }
/**
* Zeichnet ein Checkbox-Symbol rein per Canvas-Vektorprimitiven: ein Quadrat-Umriss, bei
* `checked = true` zusätzlich ein Kreuz darin. [top]/[bottom] sind die Geräte-Y-Koordinaten
* der zugehörigen ersten Textzeile - das Kästchen wird vertikal darauf zentriert.
*/
private fun drawCheckboxGlyph(
canvas: Canvas,
left: Float,
top: Float,
bottom: Float,
checked: Boolean,
paints: ExportPaints,
) {
val centerY = (top + bottom) / 2f
val boxTop = centerY - CHECKBOX_SIZE / 2f
val boxBottom = centerY + CHECKBOX_SIZE / 2f
val boxRight = left + CHECKBOX_SIZE
canvas.drawRect(left, boxTop, boxRight, boxBottom, paints.checkboxOutline)
if (checked) {
val inset = CHECKBOX_SIZE * 0.2f
canvas.drawLine(left + inset, boxTop + inset, boxRight - inset, boxBottom - inset, paints.checkboxMark)
canvas.drawLine(boxRight - inset, boxTop + inset, left + inset, boxBottom - inset, paints.checkboxMark)
}
}
/** /**
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift * Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil * "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
@@ -28,12 +28,16 @@ object DocumentationToPdfExportModelMapper {
.map { block -> .map { block ->
PdfExportBlock( PdfExportBlock(
title = block.title, title = block.title,
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
items = block.items items = block.items
.filter { it.checked }
.sortedBy { it.order } .sortedBy { it.order }
.map { item -> .map { item ->
PdfExportItem( PdfExportItem(
label = item.label, label = item.label,
checked = item.checked,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
) )
@@ -28,14 +28,15 @@ data class PdfExportModel(
data class PdfExportBlock( data class PdfExportBlock(
val title: String, val title: String,
/** Bereits sortiert nach itemOrder. Enthält ALLE Optionen des Blocks (angehakt und nicht /** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
* angehakt) - siehe Architekturentscheidung zur Checkbox-Darstellung. */ * (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
val items: List<PdfExportItem>, val items: List<PdfExportItem>,
) )
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter. */
data class PdfExportItem( data class PdfExportItem(
val label: String, val label: String,
val checked: Boolean,
val isFreeText: Boolean, val isFreeText: Boolean,
val freeTextValue: String, val freeTextValue: String,
) )
@@ -7,6 +7,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -24,7 +25,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.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.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -55,6 +59,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -95,6 +100,13 @@ fun DocumentationEditScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Vollbildansicht eines Fotos beim Antippen einer Kachel (Änderungswunsch) - rein lokaler
// UI-Zustand, bewusst nicht im ViewModel gehalten (analog zu den transient geladenen
// Thumbnail-/Signatur-Bitmaps, s. Kommentare an PhotoThumbnail/loadPhotoThumbnail): welches
// Foto gerade großformatig angezeigt wird, ist reine Präsentationslogik ohne fachliche
// Bedeutung, die einen Prozesstod überdauern müsste.
var viewedPhoto by remember { mutableStateOf<Photo?>(null) }
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren // Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall. // Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
@@ -153,8 +165,8 @@ fun DocumentationEditScreen(
if (uiState.blockCount > 0) { if (uiState.blockCount > 0) {
Text( Text(
stringResource( stringResource(
R.string.documentation_edit_position, R.string.documentation_edit_progress,
uiState.currentBlockIndex + 1, uiState.editedBlockCount,
uiState.blockCount, uiState.blockCount,
) )
) )
@@ -193,23 +205,20 @@ fun DocumentationEditScreen(
else -> { else -> {
val documentation = uiState.documentation val documentation = uiState.documentation
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) if (documentation == null || documentation.blocks.isEmpty()) {
if (documentation == null || currentBlock == null) {
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Diese Dokumentation enthält keine Blöcke.") Text("Diese Dokumentation enthält keine Blöcke.")
} }
} else { } else {
DocumentationEditContent( DocumentationEditContent(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock, blocks = documentation.blocks,
expandedBlockId = uiState.expandedBlockId,
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
photos = documentation.photos, photos = documentation.photos,
signatures = documentation.signatures, signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged, onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged, onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0,
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
onPrevious = viewModel::onPreviousBlock,
onNext = viewModel::onNextBlock,
photoImportInProgress = uiState.photoImportInProgress, photoImportInProgress = uiState.photoImportInProgress,
photoImportError = uiState.photoImportError, photoImportError = uiState.photoImportError,
onDismissPhotoImportError = viewModel::dismissPhotoImportError, onDismissPhotoImportError = viewModel::dismissPhotoImportError,
@@ -220,6 +229,7 @@ fun DocumentationEditScreen(
}, },
onDeletePhotoRequested = viewModel::requestDeletePhoto, onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail, loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it },
onAddSignatureClick = viewModel::requestAddSignature, onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature, onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap, loadSignatureBitmap = viewModel::loadSignatureBitmap,
@@ -295,32 +305,44 @@ fun DocumentationEditScreen(
}, },
) )
} }
// Änderungswunsch: Foto in Originalgröße anzeigen.
val photoToView = viewedPhoto
if (photoToView != null) {
PhotoViewerDialog(
photo = photoToView,
loadFullSize = viewModel::loadPhotoFullSize,
onDismiss = { viewedPhoto = null },
)
}
} }
/** /**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: aktueller Block (Titel, Optionen, * Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
* Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als * (Änderungswunsch) - zunächst stehen nur die Überschriften untereinander, Antippen einer
* Überschrift klappt genau diesen Block auf und schließt dabei automatisch einen zuvor
* aufgeklappten (s. [DocumentationEditViewModel.toggleBlockExpanded] - IMMER höchstens ein
* Block gleichzeitig offen) - gefolgt vom dokumentationsweiten Signatur- und Fotobereich. Als
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar * einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
* sind/werden. * sind/werden.
*/ */
@Composable @Composable
private fun DocumentationEditContent( private fun DocumentationEditContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
block: DocumentationBlock, blocks: List<DocumentationBlock>,
expandedBlockId: String?,
onToggleBlockExpanded: (blockStateId: String) -> Unit,
photos: List<Photo>, photos: List<Photo>,
signatures: List<Signature>, signatures: List<Signature>,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean,
canGoNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
photoImportInProgress: Boolean, photoImportInProgress: Boolean,
photoImportError: String?, photoImportError: String?,
onDismissPhotoImportError: () -> Unit, onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit, onAddPhotosClick: () -> 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,
onAddSignatureClick: () -> Unit, onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit, onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
@@ -330,34 +352,14 @@ private fun DocumentationEditContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item(key = "block_title") { items(blocks, key = { it.blockStateId }) { block ->
Text(block.title, style = MaterialTheme.typography.headlineSmall) BlockAccordionItem(
} block = block,
expanded = block.blockStateId == expandedBlockId,
items(block.items, key = { it.checkItemStateId }) { item -> onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
if (item.isFreeText) {
FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged, onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged, onFreeTextChanged = onFreeTextChanged,
) )
} else {
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
item(key = "block_navigation") {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
Text(stringResource(R.string.documentation_edit_previous))
}
Button(onClick = onNext, enabled = canGoNext) {
Text(stringResource(R.string.documentation_edit_next))
}
}
} }
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt // MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
@@ -451,7 +453,12 @@ private fun DocumentationEditContent(
} }
} else { } else {
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row -> items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
PhotoRow(photos = row, loadThumbnail = loadThumbnail, onDeleteRequested = onDeletePhotoRequested) PhotoRow(
photos = row,
loadThumbnail = loadThumbnail,
onDeleteRequested = onDeletePhotoRequested,
onPhotoClick = onPhotoClick,
)
} }
} }
} }
@@ -464,6 +471,7 @@ private fun PhotoRow(
photos: List<Photo>, photos: List<Photo>,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: (photoId: String) -> Unit, onDeleteRequested: (photoId: String) -> Unit,
onPhotoClick: (Photo) -> Unit,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo -> photos.forEach { photo ->
@@ -471,6 +479,7 @@ private fun PhotoRow(
photo = photo, photo = photo,
loadThumbnail = loadThumbnail, loadThumbnail = loadThumbnail,
onDeleteRequested = { onDeleteRequested(photo.id) }, onDeleteRequested = { onDeleteRequested(photo.id) },
onClick = { onPhotoClick(photo) },
) )
} }
} }
@@ -483,12 +492,17 @@ private fun PhotoRow(
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die * (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht * Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
* sichtbar ist), wird der Zustand automatisch freigegeben. * sichtbar ist), wird der Zustand automatisch freigegeben.
*
* Ein Antippen der Kachel (außerhalb des Löschen-Buttons) öffnet die Vollbildansicht
* (Änderungswunsch) - [onClick] statt eines eigenen Ladevorgangs hier, da die Kachel selbst nur
* das kleine, bereits geladene Thumbnail kennt.
*/ */
@Composable @Composable
private fun PhotoThumbnail( private fun PhotoThumbnail(
photo: Photo, photo: Photo,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit, onDeleteRequested: () -> Unit,
onClick: () -> Unit,
) { ) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) } var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) } var loadFailed by remember(photo.id) { mutableStateOf(false) }
@@ -503,7 +517,8 @@ private fun PhotoThumbnail(
modifier = Modifier modifier = Modifier
.size(96.dp) .size(96.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = bitmap != null, onClick = onClick),
) { ) {
val currentBitmap = bitmap val currentBitmap = bitmap
when { when {
@@ -611,6 +626,73 @@ private fun SignatureRow(
} }
} }
/**
* Ein einzelner Block im Akkordeon (Änderungswunsch): eine antippbare Kopfzeile mit Titel,
* einem kleinen "bearbeitet"-Indikator (grüner Haken, s. [DocumentationBlock.isEdited]) und
* einem Pfeil-Icon, das den Auf-/Zugeklappt-Zustand anzeigt. Der eigentliche Inhalt (Checkboxen
* inkl. Freitext-Option) wird ausschließlich gerendert, wenn [expanded] `true` ist - das
* entspricht fachlich einem Dropdown/Expansion-Panel, technisch einfach bedingtes Komponieren
* ohne eigene Lazy-Liste (ein Block hat nur wenige Optionen).
*/
@Composable
private fun BlockAccordionItem(
block: DocumentationBlock,
expanded: Boolean,
onHeaderClick: () -> Unit,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onHeaderClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (block.isEdited) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.documentation_edit_block_edited),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
}
Text(
block.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
)
}
if (expanded) {
Column(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
for (item in block.items) {
when {
item.isStaticHint -> StaticHintRow(item = item)
item.isFreeText -> FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
}
}
HorizontalDivider()
}
}
@Composable @Composable
private fun CheckItemRow( private fun CheckItemRow(
item: CheckItem, item: CheckItem,
@@ -654,3 +736,18 @@ private fun FreeTextCheckItemRow(
) )
} }
} }
/**
* Ein rein anzeigender, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" bei "Empfohlene
* Wundversorgung") - keine Checkbox, keine Interaktion, [item.label] ist direkt der
* anzuzeigende Text selbst. Optisch dezent (kursiv, gedämpfte Farbe) abgesetzt von echten,
* ausfüllbaren Optionen, damit auf den ersten Blick klar ist, dass hier nichts einzugeben ist.
*/
@Composable
private fun StaticHintRow(item: CheckItem) {
Text(
item.label,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -28,7 +28,11 @@ import java.io.File
data class DocumentationEditUiState( data class DocumentationEditUiState(
val documentation: Documentation? = null, val documentation: Documentation? = null,
val currentBlockIndex: Int = 0, /** ID des BlockState, der gerade im Akkordeon aufgeklappt ist - `null`, wenn keiner
* aufgeklappt ist (Änderungswunsch: zunächst nur alle Überschriften untereinander, Antippen
* einer Überschrift klappt sie auf). Es ist IMMER höchstens ein Block gleichzeitig
* aufgeklappt, s. [DocumentationEditViewModel.toggleBlockExpanded]. */
val expandedBlockId: String? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */ /** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
@@ -61,6 +65,13 @@ data class DocumentationEditUiState(
val lastExportedPdfFile: File? = null, val lastExportedPdfFile: File? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
/** Anzahl bereits bearbeiteter Blöcke (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy])
* - ersetzt die frühere "X / Y"-Positionsanzeige, die sich auf den einzelnen, gerade
* angezeigten Block bezog; im Akkordeon-Layout sind ALLE Blöcke gleichzeitig sichtbar
* (nur der Inhalt ist ein-/ausklappbar), daher ist ein Fortschritt sinnvoller als eine
* Position. */
val editedBlockCount: Int get() = documentation?.blocks?.count { it.isEdited } ?: 0
} }
/** /**
@@ -163,17 +174,17 @@ class DocumentationEditViewModel(
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) } viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
} }
fun onNextBlock() { /**
* Klappt den Block mit [blockStateId] auf bzw. zu (Änderungswunsch: Akkordeon statt
* Vor-/Zurück-Navigation). Antippen eines bereits aufgeklappten Blocks klappt ihn zu;
* Antippen eines anderen Blocks klappt diesen auf und ersetzt dabei automatisch einen zuvor
* aufgeklappten Block - es ist also IMMER höchstens ein Block gleichzeitig aufgeklappt,
* ganz ohne zusätzliche Schließen-Logik für den vorherigen.
*/
fun toggleBlockExpanded(blockStateId: String) {
_uiState.update { state -> _uiState.update { state ->
val lastIndex = state.blockCount - 1 val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
if (lastIndex < 0) return@update state state.copy(expandedBlockId = newExpandedId)
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
}
}
fun onPreviousBlock() {
_uiState.update { state ->
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
} }
} }
@@ -365,6 +376,10 @@ class DocumentationEditViewModel(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName) repository.loadPhotoThumbnail(documentationId, fileName)
/** Für die Vollbildansicht beim Antippen einer Foto-Kachel (Änderungswunsch). */
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) =
repository.loadPhotoFullSize(documentationId, fileName, maxWidthPx, maxHeightPx)
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog /** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */ * [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) = suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
@@ -0,0 +1,124 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.Photo
/**
* Vollbild-Ansicht eines einzelnen Fotos in Originalgröße (Änderungswunsch: "Fotos in der App
* durch Antippen in Originalgröße angezeigt werden"). Lädt das Foto über [loadFullSize] separat
* von der kleinen Grid-Vorschau ([DocumentationEditViewModel.loadPhotoThumbnail]) - dieselbe
* Trennung wie bei den Signatur-Vorschauen: die kleine Kachel und die Vollbildansicht sind
* unabhängige Ladevorgänge mit unterschiedlicher Zielauflösung.
*
* "Originalgröße" bedeutet hier: so groß wie der Bildschirm es zulässt, ohne unnötig über die
* tatsächliche Displayauflösung hinaus zu decodieren (s. Kommentar an
* [de.medapp.wunddoku.data.repository.DocumentationRepository.loadPhotoFullSize]) - eine echte
* 1:1-Pixel-Darstellung würde bei modernen Fotoauflösungen ohnehin über die Bildschirmgröße
* hinausgehen und wäre nur per Zoom/Scroll sichtbar, was hier nicht gefordert ist.
*
* Volle Bildschirmbreite/-höhe via [DialogProperties.usePlatformDefaultWidth] = false, da der
* Standard-Dialog-Rahmen von Compose sonst deutlich schmaler als der Bildschirm wäre. Schließen
* über den Schließen-Button ODER Antippen des dunklen Hintergrunds.
*/
@Composable
fun PhotoViewerDialog(
photo: Photo,
loadFullSize: suspend (documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) -> Bitmap?,
onDismiss: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
val configuration = LocalConfiguration.current
val density = LocalDensity.current
LaunchedEffect(photo.id) {
val maxWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
val maxHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() }
val result = runCatching {
loadFullSize(photo.documentationId, photo.fileName, maxWidthPx, maxHeightPx)
}.getOrNull()
bitmap = result
loadFailed = result == null
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(16.dp),
contentScale = ContentScale.Fit,
)
loadFailed -> Box(modifier = Modifier.fillMaxSize()) {
Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
tint = Color.White,
modifier = Modifier.align(Alignment.Center).size(48.dp),
)
Text(
stringResource(R.string.documentation_edit_photo_load_failed),
color = Color.White,
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
)
}
else -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color.White,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_cancel), tint = Color.White)
}
}
}
}
+3 -3
View File
@@ -32,14 +32,14 @@
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string> <string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
<string name="patient_form_save">Speichern</string> <string name="patient_form_save">Speichern</string>
<string name="documentation_edit_position">%1$d / %2$d</string> <string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
<string name="documentation_edit_previous">Zurück</string> <string name="documentation_edit_block_edited">Block bearbeitet</string>
<string name="documentation_edit_next">Weiter</string>
<string name="documentation_edit_free_text_label">Freitext</string> <string name="documentation_edit_free_text_label">Freitext</string>
<string name="documentation_edit_photos_title">Fotos</string> <string name="documentation_edit_photos_title">Fotos</string>
<string name="documentation_edit_add_photos">Hinzufügen</string> <string name="documentation_edit_add_photos">Hinzufügen</string>
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string> <string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
<string name="documentation_edit_photo_load_failed">Foto konnte nicht geladen werden.</string>
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string> <string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
<string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string> <string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>