Compare commits

...

43 Commits

Author SHA1 Message Date
niklas.ortmann 4eaf1480a2 Dateien nach "/" hochladen 2026-10-09 15:48:30 +02:00
niklas.ortmann 4a29d653bc Merge pull request 'QA-002' (#16) from dev-QA002 into main
Reviewed-on: #16
2026-10-09 10:08:04 +02:00
niklas.ortmann 77f71973a7 QA-002
Room-Tests
2026-10-09 10:07:17 +02:00
niklas.ortmann 51370a9172 Merge pull request 'QA-001' (#15) from dev-QA001 into dev
Reviewed-on: #15
2026-10-09 10:02:08 +02:00
niklas.ortmann 26cc81388e Update PatientRepositoryTest.kt 2026-10-09 10:01:32 +02:00
niklas.ortmann dcfa11bef9 QA-001
- [x] Dateinamen-Tests vorhanden
- [x] Fortsetzen-Tests vorhanden
- [x] Blockstatus-Tests vorhanden
- [x] Repository-Tests vorhanden
2026-10-08 12:15:31 +02:00
niklas.ortmann 85600bfaed Merge pull request 'beta 1.2 release' (#14) from dev into main
Reviewed-on: #14
2026-10-08 00:22:57 +02:00
niklas.ortmann e6bc99d144 Update DocumentationEditScreen.kt 2026-10-08 00:22:06 +02:00
niklas.ortmann 2bc0d959e1 README.md aktualisiert 2026-10-07 11:49:41 +02:00
niklas.ortmann 42e4b5c78c README.md aktualisiert 2026-10-07 11:49:23 +02:00
niklas.ortmann b265f3fadb README.md aktualisiert 2026-10-07 11:49:01 +02:00
niklas.ortmann e8ccab4f00 README.md aktualisiert 2026-10-07 11:48:25 +02:00
niklas.ortmann b77f7f0f20 README.md aktualisiert 2026-10-07 11:48:14 +02:00
niklas.ortmann 70bdc84cfa README.md aktualisiert 2026-10-07 11:47:54 +02:00
niklas.ortmann 76bf0f16ec README.md aktualisiert 2026-10-07 11:47:42 +02:00
niklas.ortmann 52a95e2b54 README.md aktualisiert 2026-10-07 11:45:07 +02:00
niklas.ortmann cefecb86dd README.md aktualisiert 2026-10-07 11:44:59 +02:00
niklas.ortmann 08f24aadd5 Merge pull request 'kleine Inhaltliche Korrekturen' (#12) from dev-Inhalte2 into main
Reviewed-on: #12
2026-10-06 09:00:16 +02:00
niklas.ortmann 732c5b73c0 Update DocumentationCatalog.kt 2026-10-06 08:59:17 +02:00
niklas.ortmann f25596563c beta1.0-release 2026-10-03 00:47:14 +02:00
niklas.ortmann b5ef793513 Merge pull request 'Beta 1.0 Release' (#11) from dev into main
Reviewed-on: #11
2026-10-03 00:37:20 +02:00
niklas.ortmann 1fdcceb149 Merge pull request 'neue Boxinhalte nach Kundenwunsch' (#10) from dev-Inhalte2 into dev
Reviewed-on: #10
2026-10-03 00:35:27 +02:00
niklas.ortmann 8a430d5089 neue Boxinhalte nach Kundenwunsch 2026-10-03 00:34:59 +02:00
niklas.ortmann 0c7eaabbb7 Merge pull request 'MEDIA-005' (#9) from dev-MEDIA-005 into dev
Reviewed-on: #9
2026-09-27 17:28:09 +02:00
niklas.ortmann 63bc93ebd2 MEDIA-005
Export fertig
2026-09-27 17:27:14 +02:00
niklas.ortmann 0f4ce2d2e1 Merge pull request 'MEDIA-005' (#8) from dev-MEDIA-005 into dev
Reviewed-on: #8
2026-09-24 16:49:06 +02:00
niklas.ortmann 08091a15a2 MEDIA-005
JPG-Export
2026-09-24 13:28:22 +02:00
niklas.ortmann 4a62d195ac Merge pull request 'Box-Inhalte' (#7) from dev-Inhalte into dev
Reviewed-on: #7
2026-09-24 13:13:23 +02:00
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
niklas.ortmann c52229b7f7 PDF/MEDIA
MEDIA-003
MEDIA-004
PDF-003
2026-09-06 21:19:58 +02:00
niklas.ortmann 91e68c827a PDF-004 2026-08-27 08:10:18 +02:00
niklas.ortmann 8c05a513fe PDF-005 2026-08-25 09:50:31 +02:00
niklas.ortmann ebf85df188 PDF-001 & PDF-002 (ungetestet) 2026-08-25 01:27:29 +02:00
niklas.ortmann 81178cbbc2 Merge pull request 'MEDIA-001 & MEDIA-002' (#4) from dev-MEDIA-001 into dev
Reviewed-on: #4
2026-08-23 23:00:00 +02:00
niklas.ortmann 139dd9f9ec Update DocumentationRepository.kt 2026-08-23 22:59:10 +02:00
niklas.ortmann 50af2a753e Update .gitignore 2026-08-23 22:59:07 +02:00
niklas.ortmann 4ffa6db59b bugfix 2026-08-23 22:54:00 +02:00
niklas.ortmann 9cb49dda5c MEDIA-001 & MEDIA-002 2026-08-23 22:45:13 +02:00
niklas.ortmann 84f3ba40e6 Merge pull request 'Grundlegende Funktionalitäten (Formulare, Speichern, Durchsuchen, etc.)' (#3) from dev-FUNC into dev
Reviewed-on: #3
2026-08-22 11:12:07 +02:00
69 changed files with 11691 additions and 280 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ local.properties
.DS_Store
Thumbs.db
# Android profiling
# Android proFiling
*.hprof
# Signing keys
+37 -77
View File
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
unterstützt Fotos und digitale Unterschriften und erzeugt aus
abgeschlossenen Dokumentationen PDF-Dateien.
> **Projektstatus:** In Entwicklung
> **Projektstatus:** beta1.1
------------------------------------------------------------------------
@@ -344,32 +344,6 @@ werden kann.
- JDK über Android Studio
- Android-Gerät oder Android Emulator
### Projekt öffnen
Repository klonen:
``` bash
git clone <repository-url>
cd VulneraDoc
```
Anschließend den Projektordner in Android Studio öffnen.
Android Studio führt beim ersten Öffnen einen Gradle-Sync durch und lädt
die benötigten Dependencies.
Danach:
``` text
Gradle Sync
↓
Build / Make Project
↓
Android-Gerät oder Emulator auswählen
↓
Run
```
------------------------------------------------------------------------
## Projektphasen
@@ -387,40 +361,40 @@ Run
### Phase 2 -- Projektgrundgerüst
- [x] Android-Projekt
- [ ] Room-Datenbank
- [ ] Entities
- [ ] DAOs
- [ ] Repositories
- [ ] Navigation
- [ ] ViewModels
- [x] Room-Datenbank
- [x] Entities
- [x] DAOs
- [x] Repositories
- [x] Navigation
- [x] ViewModels
### Phase 3 -- Kernfunktionalität
- [ ] Patientenverwaltung
- [ ] Patientenliste
- [ ] Wunddokumentationsliste
- [ ] Dokumentation erstellen
- [ ] Dokumentationsblöcke
- [ ] Blocknavigation
- [ ] Autosave
- [ ] Dokumentation bearbeiten
- [ ] Dokumentation fortsetzen
- [x] Patientenverwaltung
- [x] Patientenliste
- [x] Wunddokumentationsliste
- [x] Dokumentation erstellen
- [x] Dokumentationsblöcke
- [x] Blocknavigation
- [x] Autosave
- [x] Dokumentation bearbeiten
- [x] Dokumentation fortsetzen
### Phase 4 -- Medien
- [ ] Fotoimport
- [ ] Bildverwaltung
- [ ] Digitale Signatur
- [ ] Mehrere Signaturen
- [x] Fotoimport
- [x] Bildverwaltung
- [x] Digitale Signatur
- [x] Mehrere Signaturen
### Phase 5 -- PDF
- [ ] PDF-Exporter
- [ ] Dokumentationsblöcke
- [ ] Signaturen
- [ ] Fotoanhang
- [ ] PDF anzeigen
- [ ] PDF teilen
- [x] PDF-Exporter
- [x] Dokumentationsblöcke
- [x] Signaturen
- [x] Fotoanhang
- [x] PDF anzeigen
- [x] PDF teilen
### Phase 6 -- Qualität und Stabilität
@@ -431,29 +405,15 @@ Run
- [ ] Tablet-Optimierung
- [ ] Datenschutz-Review
------------------------------------------------------------------------
### Phase 7 -- Multi-Device-Synchronisation
## Dokumentation
Die ausführliche technische Architektur befindet sich in der
Projektdokumentation:
``` text
docs/
└── Architekturplanung.md
```
Dort werden unter anderem Datenmodell, Datenbankschema,
Navigationskonzept, Autosave, PDF-Erzeugung und Medienverwaltung
detailliert beschrieben.
------------------------------------------------------------------------
## Status
**VulneraDoc befindet sich aktuell in Entwicklung und ist nicht für den
produktiven Einsatz freigegeben.**
Insbesondere bei der Verarbeitung realer Patientendaten müssen vor einem
Produktiveinsatz die rechtlichen, datenschutzrechtlichen und
sicherheitstechnischen Anforderungen abschließend geprüft werden.
- [ ] Geräteidentität und Pairing
- [ ] Automatische Geräteerkennung im WLAN
- [ ] Differenzabgleich / Sync-Protokoll
- [ ] Patienten synchronisieren
- [ ] Wunddokumentationen synchronisieren
- [ ] Fotos synchronisieren
- [ ] Konflikterkennung und Konfliktauflösung
- [ ] Automatische Synchronisation
- [ ] Sync-Status und manueller Fallback
- [ ] Multi-Device- und Recovery-Tests
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "de.medapp.wunddoku",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}
@@ -0,0 +1,585 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "cd000019287c8c50d6b8d1b239aa12f0",
"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, 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
}
],
"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"
]
}
]
}
],
"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, 'cd000019287c8c50d6b8d1b239aa12f0')"
]
}
}
@@ -0,0 +1,623 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "182fcff50c0191c9414187dd7b7cacd2",
"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, 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
}
],
"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, '182fcff50c0191c9414187dd7b7cacd2')"
]
}
}
@@ -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')"
]
}
}
@@ -0,0 +1,635 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "5e5d96efeef00d1ff7d404166a900974",
"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, `inputSpec` TEXT 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
},
{
"fieldPath": "inputSpec",
"columnName": "inputSpec",
"affinity": "TEXT",
"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, '5e5d96efeef00d1ff7d404166a900974')"
]
}
}
@@ -0,0 +1,259 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [BlockStateDao] und [CheckItemStateDao]. */
class BlockAndCheckItemDaoTest : RoomTestBase() {
// --- BlockStateDao -------------------------------------------------------------------
@Test
fun blockStates_insertAll_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val block = blockStateEntity(title = "Größe & Tiefe", order = 3, updatedAt = 4_000L)
blockStateDao.insertAll(listOf(block))
assertEquals(block, blockStateDao.getById("b-1"))
assertNull(blockStateDao.getById("unbekannt"))
}
@Test
fun blockStates_getForDocumentation_sortiert_nach_blockOrder_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-3", documentationId = "d-1", blockDefinitionId = "c", order = 3),
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "a", order = 1),
blockStateEntity(id = "b-2", documentationId = "d-1", blockDefinitionId = "b", order = 2),
blockStateEntity(id = "b-x", documentationId = "d-2", blockDefinitionId = "a", order = 1),
)
)
assertEquals(listOf("b-1", "b-2", "b-3"), blockStateDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("b-x"), blockStateDao.getForDocumentation("d-2").map { it.id })
assertTrue(blockStateDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun blockStates_gleiche_blockdefinition_in_derselben_dokumentation_ist_verboten() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-1", blockDefinitionId = "wundart")))
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-2", blockDefinitionId = "wundart")))
}
assertEquals(1, countRows("block_states"))
}
@Test
fun blockStates_gleiche_blockdefinition_in_verschiedenen_dokumentationen_ist_erlaubt() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", documentationId = "d-2", blockDefinitionId = "wundart"),
)
)
assertEquals(2, countRows("block_states"))
}
@Test
fun blockStates_mit_unbekannter_dokumentation_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(documentationId = "gibt-es-nicht")))
}
assertEquals(0, countRows("block_states"))
}
@Test
fun blockStates_insertAll_mit_leerer_liste_ist_unkritisch() = runDbTest {
blockStateDao.insertAll(emptyList())
assertEquals(0, countRows("block_states"))
}
// --- CheckItemStateDao ---------------------------------------------------------------
private suspend fun seedBlock(id: String = "b-1", documentationId: String = "d-1", definitionId: String = "wundart") {
if (documentationDao.getById(documentationId) == null) seedDocumentation(id = documentationId)
blockStateDao.insertAll(listOf(blockStateEntity(id = id, documentationId = documentationId, blockDefinitionId = definitionId)))
}
@Test
fun checkItems_insertAll_und_getForBlockState_liefern_alle_felder_inklusive_standardwerten() = runDbTest {
seedBlock()
val plain = checkItemStateEntity(id = "c-1", definitionId = "plain", order = 1)
val full = checkItemStateEntity(
id = "c-2", definitionId = "full", order = 2, checked = true, isFreeText = true,
freeTextValue = "Text", isStaticHint = false, inputSpec = "SPEC",
)
checkItemStateDao.insertAll(listOf(plain, full))
val loaded = checkItemStateDao.getForBlockState("b-1")
assertEquals(listOf(plain, full), loaded)
assertFalse(loaded[0].checked)
assertFalse(loaded[0].isFreeText)
assertEquals("", loaded[0].freeTextValue)
assertFalse(loaded[0].isStaticHint)
assertEquals("", loaded[0].inputSpec)
}
@Test
fun checkItems_getForBlockState_sortiert_nach_itemOrder_und_filtert_nach_block() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-3", blockStateId = "b-1", definitionId = "z", order = 3),
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "x", order = 1),
checkItemStateEntity(id = "c-2", blockStateId = "b-1", definitionId = "y", order = 2),
checkItemStateEntity(id = "c-x", blockStateId = "b-2", definitionId = "x", order = 1),
)
)
assertEquals(listOf("c-1", "c-2", "c-3"), checkItemStateDao.getForBlockState("b-1").map { it.id })
assertEquals(listOf("c-x"), checkItemStateDao.getForBlockState("b-2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("unbekannt").isEmpty())
}
@Test
fun checkItems_setChecked_setzt_und_entfernt_den_haken_ohne_andere_felder_zu_aendern() = runDbTest {
seedBlock()
val item = checkItemStateEntity(freeTextValue = "bleibt", inputSpec = "bleibt auch")
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("c-1", true)
assertEquals(item.copy(checked = true), checkItemStateDao.getForBlockState("b-1").single())
checkItemStateDao.setChecked("c-1", false)
assertEquals(item, checkItemStateDao.getForBlockState("b-1").single())
}
@Test
fun checkItems_setChecked_betrifft_nur_die_angegebene_option() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", definitionId = "a", order = 1),
checkItemStateEntity(id = "c-2", definitionId = "b", order = 2),
)
)
checkItemStateDao.setChecked("c-1", true)
val byId = checkItemStateDao.getForBlockState("b-1").associateBy { it.id }
assertTrue(byId.getValue("c-1").checked)
assertFalse(byId.getValue("c-2").checked)
}
@Test
fun checkItems_setChecked_und_updateFreeTextValue_mit_unbekannter_id_sind_wirkungslos() = runDbTest {
seedBlock()
val item = checkItemStateEntity()
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("unbekannt", true)
checkItemStateDao.updateFreeTextValue("unbekannt", "x")
assertEquals(listOf(item), checkItemStateDao.getForBlockState("b-1"))
}
@Test
fun checkItems_updateFreeTextValue_aendert_nur_den_text_und_nicht_den_haken() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, checked = true)))
checkItemStateDao.updateFreeTextValue("c-1", "Rötung am Wundrand")
val loaded = checkItemStateDao.getForBlockState("b-1").single()
assertEquals("Rötung am Wundrand", loaded.freeTextValue)
assertTrue(loaded.checked)
assertTrue(loaded.isFreeText)
}
@Test
fun checkItems_freitext_laesst_sich_leeren() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, freeTextValue = "alt")))
checkItemStateDao.updateFreeTextValue("c-1", "")
assertEquals("", checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
}
@Test
fun checkItems_sonderzeichen_und_sql_fragmente_werden_als_reiner_text_gespeichert() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true)))
val text = "O'Brien \"Test\" ; DROP TABLE patients; -- Größe 3,5 × 2 cm\nzweite Zeile\t😀 100% _x_"
checkItemStateDao.updateFreeTextValue("c-1", text)
assertEquals(text, checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
assertEquals(1, countRows("patients")) // Tabelle existiert weiterhin, der Seed-Patient ist unangetastet
}
@Test
fun checkItems_inputSpec_mit_steuerzeichen_der_codierung_bleibt_verlustfrei_erhalten() = runDbTest {
// InputSpecCodec nutzt \u001E, \u001F und \u001D als Trennzeichen.
seedBlock()
val spec = "tpl\u001EN\u001Flabel\u001F3\u001Dx\u001Ey"
checkItemStateDao.insertAll(listOf(checkItemStateEntity(inputSpec = spec)))
assertEquals(spec, checkItemStateDao.getForBlockState("b-1").single().inputSpec)
}
@Test
fun checkItems_gleiche_optionsdefinition_im_selben_block_ist_verboten() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-1", definitionId = "dup")))
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-2", definitionId = "dup")))
}
assertEquals(1, countRows("check_item_states"))
}
@Test
fun checkItems_gleiche_optionsdefinition_in_verschiedenen_bloecken_ist_erlaubt() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "same"),
checkItemStateEntity(id = "c-2", blockStateId = "b-2", definitionId = "same"),
)
)
assertEquals(2, countRows("check_item_states"))
}
@Test
fun checkItems_mit_unbekanntem_block_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(blockStateId = "gibt-es-nicht")))
}
assertEquals(0, countRows("check_item_states"))
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [DocumentationDao] (ohne Suche und Relationen, s. SearchQueryTest / DocumentationRelationsTest). */
class DocumentationDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_inklusive_snapshots() = runDbTest {
seedPatient()
val entity = documentationEntity(
status = DocumentationStatus.COMPLETED,
createdAt = 1_000L,
updatedAt = 2_000L,
pdfLastGeneratedAt = 3_000L,
catalogVersion = 3,
firstNameSnapshot = "Erika",
lastNameSnapshot = "Musterfrau",
birthDateSnapshot = "1975-12-01",
streetSnapshot = "Hauptstraße 5",
postalCodeSnapshot = "54321",
citySnapshot = "Beispielstadt",
)
documentationDao.insert(entity)
assertEquals(entity, documentationDao.getById("d-1"))
}
@Test
fun nullable_felder_bleiben_null() = runDbTest {
seedDocumentation()
val loaded = documentationDao.getById("d-1")!!
assertNull(loaded.continuedFromDocumentationId)
assertNull(loaded.pdfLastGeneratedAt)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getById("unbekannt"))
}
@Test
fun status_wird_als_enum_name_gespeichert_und_vollstaendig_zurueckgelesen() = runDbTest {
seedPatient()
DocumentationStatus.values().forEachIndexed { index, status ->
documentationDao.insert(documentationEntity(id = "d-$index", status = status))
}
DocumentationStatus.values().forEachIndexed { index, status ->
assertEquals(status, documentationDao.getById("d-$index")!!.status)
assertEquals(status.name, queryString("SELECT status FROM documentations WHERE id = 'd-$index'"))
}
}
// --- Foreign Keys --------------------------------------------------------------------
@Test
fun insert_mit_unbekanntem_patienten_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(patientId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun insert_mit_unbekannter_fortsetzungsquelle_wirft() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(continuedFromDocumentationId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun fortsetzungsquelle_wird_beim_loeschen_der_quelle_auf_null_gesetzt() = runDbTest {
val source = seedDocumentation(id = "d-source")
documentationDao.insert(documentationEntity(id = "d-copy", continuedFromDocumentationId = "d-source"))
assertEquals("d-source", documentationDao.getById("d-copy")!!.continuedFromDocumentationId)
documentationDao.delete(source)
assertNull(documentationDao.getById("d-source"))
val copy = documentationDao.getById("d-copy")
assertNotNull("Die Fortsetzung darf nicht mitgelöscht werden", copy)
assertNull(copy!!.continuedFromDocumentationId)
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_aendert_nur_die_uebergebene_dokumentation() = runDbTest {
val first = seedDocumentation(id = "d-1")
val second = seedDocumentation(id = "d-2")
documentationDao.update(first.copy(status = DocumentationStatus.COMPLETED, updatedAt = 9_000L, pdfLastGeneratedAt = 9_500L))
val changed = documentationDao.getById("d-1")!!
assertEquals(DocumentationStatus.COMPLETED, changed.status)
assertEquals(9_000L, changed.updatedAt)
assertEquals(9_500L, changed.pdfLastGeneratedAt)
assertEquals(second, documentationDao.getById("d-2"))
}
@Test
fun update_einer_unbekannten_id_legt_nichts_an() = runDbTest {
seedPatient()
documentationDao.update(documentationEntity(id = "geist"))
assertNull(documentationDao.getById("geist"))
}
@Test
fun delete_entfernt_nur_die_uebergebene_dokumentation_und_laesst_den_patienten_bestehen() = runDbTest {
val first = seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
documentationDao.delete(first)
assertNull(documentationDao.getById("d-1"))
assertNotNull(documentationDao.getById("d-2"))
assertNotNull(patientDao.getById("p-1"))
}
// --- observeAll / countForPatient ----------------------------------------------------
@Test
fun observeAll_sortiert_nach_erstelldatum_absteigend() = runDbTest {
seedDocumentation(id = "d-alt", createdAt = 1_000L)
seedDocumentation(id = "d-neu", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", createdAt = 2_000L)
val ids = documentationDao.observeAll().first().map { it.id }
assertEquals(listOf("d-neu", "d-mitte", "d-alt"), ids)
}
@Test
fun observeAll_liefert_leere_liste_ohne_dokumentationen() = runDbTest {
assertTrue(documentationDao.observeAll().first().isEmpty())
}
@Test
fun observeAll_aktualisiert_sich_bei_insert_update_und_delete() = runDbTest {
seedPatient()
val afterInsert = async { documentationDao.observeAll().awaitMatching { it.size == 1 } }
val entity = documentationEntity(id = "d-1")
documentationDao.insert(entity)
assertEquals("d-1", afterInsert.await().single().id)
val afterUpdate = async {
documentationDao.observeAll().awaitMatching { list -> list.singleOrNull()?.status == DocumentationStatus.COMPLETED }
}
documentationDao.update(entity.copy(status = DocumentationStatus.COMPLETED))
afterUpdate.await()
val afterDelete = async { documentationDao.observeAll().awaitMatching { it.isEmpty() } }
documentationDao.delete(entity)
assertTrue(afterDelete.await().isEmpty())
}
@Test
fun countForPatient_zaehlt_nur_dokumentationen_des_jeweiligen_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1")
seedDocumentation(id = "d-2", patientId = "p-1")
seedDocumentation(id = "d-3", patientId = "p-2")
assertEquals(2, documentationDao.countForPatient("p-1"))
assertEquals(1, documentationDao.countForPatient("p-2"))
assertEquals(0, documentationDao.countForPatient("unbekannt"))
}
// --- getLatestForPatient -------------------------------------------------------------
@Test
fun getLatestForPatient_liefert_die_zuletzt_erstellte_dokumentation_des_patienten() = runDbTest {
seedDocumentation(id = "d-alt", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-neu", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", patientId = "p-1", createdAt = 2_000L)
assertEquals("d-neu", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
}
@Test
fun getLatestForPatient_ignoriert_dokumentationen_anderer_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-2", patientId = "p-2", createdAt = 9_000L)
assertEquals("d-1", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
assertEquals("d-2", documentationDao.getLatestForPatient("p-2")?.documentation?.id)
}
@Test
fun getLatestForPatient_liefert_null_ohne_dokumentation() = runDbTest {
seedPatient()
assertNull(documentationDao.getLatestForPatient("p-1"))
assertNull(documentationDao.getLatestForPatient("unbekannt"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Relationstests (QA-002): [DocumentationWithDetails] (Dokumentation -> Blöcke -> Optionen,
* Fotos, Signaturen) sowie Cascade-/Restrict-Verhalten der Foreign Keys.
*/
class DocumentationRelationsTest : RoomTestBase() {
/**
* Dokumentation [docId] mit 2 Blöcken ("<prefix>-b1" order 2, "<prefix>-b2" order 1),
* 3 Optionen (b1: c1, c2 / b2: c3), 2 Fotos und 2 Signaturen.
*/
private suspend fun seedWithChildren(docId: String, createdAt: Long, prefix: String) {
seedDocumentation(id = docId, patientId = "p-1", createdAt = createdAt)
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "$prefix-b1", documentationId = docId, blockDefinitionId = "wundart", order = 2),
blockStateEntity(id = "$prefix-b2", documentationId = docId, blockDefinitionId = "groesse", order = 1),
)
)
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "$prefix-c1", blockStateId = "$prefix-b1", definitionId = "a", order = 1),
checkItemStateEntity(id = "$prefix-c2", blockStateId = "$prefix-b1", definitionId = "b", order = 2),
checkItemStateEntity(id = "$prefix-c3", blockStateId = "$prefix-b2", definitionId = "a", order = 1),
)
)
photoDao.insert(photoEntity(id = "$prefix-ph1", documentationId = docId, orderIndex = 0))
photoDao.insert(photoEntity(id = "$prefix-ph2", documentationId = docId, orderIndex = 1))
signatureDao.insert(signatureEntity(id = "$prefix-s1", documentationId = docId, orderIndex = 0))
signatureDao.insert(signatureEntity(id = "$prefix-s2", documentationId = docId, orderIndex = 1))
}
private fun DocumentationWithDetails.itemIdsByBlock(): Map<String, Set<String>> =
blocks.associate { it.blockState.id to it.checkItems.map { item -> item.id }.toSet() }
// --- Laden ---------------------------------------------------------------------------
@Test
fun getFullDocumentation_liefert_dokumentation_mit_allen_kindern() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals("d-a", full.documentation.id)
assertEquals(setOf("a-b1", "a-b2"), full.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("a-ph1", "a-ph2"), full.photos.map { it.id }.toSet())
assertEquals(setOf("a-s1", "a-s2"), full.signatures.map { it.id }.toSet())
}
@Test
fun jede_option_haengt_am_richtigen_block() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(
mapOf("a-b1" to setOf("a-c1", "a-c2"), "a-b2" to setOf("a-c3")),
full.itemIdsByBlock(),
)
}
@Test
fun relation_enthaelt_die_vollstaendigen_entities_nicht_nur_ids() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(documentationDao.getById("d-a"), full.documentation)
assertEquals(blockStateDao.getById("a-b1"), full.blocks.single { it.blockState.id == "a-b1" }.blockState)
assertEquals(photoDao.getById("a-ph1"), full.photos.single { it.id == "a-ph1" })
assertEquals(signatureDao.getById("a-s1"), full.signatures.single { it.id == "a-s1" })
assertEquals(
checkItemStateDao.getForBlockState("a-b1"),
full.blocks.single { it.blockState.id == "a-b1" }.checkItems.sortedBy { it.itemOrder },
)
}
@Test
fun relation_vermischt_keine_daten_verschiedener_dokumentationen() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val a = documentationDao.getFullDocumentation("d-a")!!
val b = documentationDao.getFullDocumentation("d-b")!!
assertTrue(a.blocks.all { it.blockState.documentationId == "d-a" })
assertTrue(a.photos.all { it.documentationId == "d-a" })
assertTrue(a.signatures.all { it.documentationId == "d-a" })
assertEquals(setOf("a-c1", "a-c2", "a-c3"), a.itemIdsByBlock().values.flatten().toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), b.itemIdsByBlock().values.flatten().toSet())
}
@Test
fun dokumentation_ohne_kinder_liefert_leere_listen_statt_null() = runDbTest {
seedDocumentation(id = "d-leer")
val full = documentationDao.getFullDocumentation("d-leer")!!
assertTrue(full.blocks.isEmpty())
assertTrue(full.photos.isEmpty())
assertTrue(full.signatures.isEmpty())
}
@Test
fun block_ohne_optionen_liefert_leere_optionsliste() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
val block = documentationDao.getFullDocumentation("d-1")!!.blocks.single()
assertTrue(block.checkItems.isEmpty())
}
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getFullDocumentation("unbekannt"))
}
@Test
fun getLatestForPatient_laedt_auch_die_kinder_der_neuesten_dokumentation() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val latest = documentationDao.getLatestForPatient("p-1")!!
assertEquals("d-b", latest.documentation.id)
assertEquals(setOf("b-b1", "b-b2"), latest.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), latest.itemIdsByBlock().values.flatten().toSet())
assertEquals(2, latest.photos.size)
assertEquals(2, latest.signatures.size)
}
@Test
fun grosse_dokumentation_wird_vollstaendig_und_korrekt_zugeordnet_geladen() = runDbTest {
seedDocumentation()
val blocks = (1..25).map { b ->
blockStateEntity(id = "b-$b", blockDefinitionId = "block_$b", title = "Block $b", order = b)
}
blockStateDao.insertAll(blocks)
val items = (1..25).flatMap { b ->
(1..20).map { i ->
checkItemStateEntity(id = "c-$b-$i", blockStateId = "b-$b", definitionId = "item_$i", order = i)
}
}
checkItemStateDao.insertAll(items)
val full = documentationDao.getFullDocumentation("d-1")!!
assertEquals(25, full.blocks.size)
assertEquals(500, full.blocks.sumOf { it.checkItems.size })
for (block in full.blocks) {
assertEquals(20, block.checkItems.size)
assertTrue(block.checkItems.all { it.blockStateId == block.blockState.id })
}
}
// --- Cascade / Restrict --------------------------------------------------------------
@Test
fun foreign_keys_sind_in_der_testdatenbank_aktiviert() = runDbTest {
assertEquals(1, queryInt("PRAGMA foreign_keys"))
}
@Test
fun dokumentation_loeschen_entfernt_bloecke_optionen_fotos_und_signaturen_per_cascade() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertEquals(0, countRows("documentations"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertEquals(0, countRows("photos"))
assertEquals(0, countRows("signatures"))
}
@Test
fun dokumentation_loeschen_laesst_patient_und_andere_dokumentationen_unangetastet() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertNotNull(patientDao.getById("p-1"))
val remaining = documentationDao.getFullDocumentation("d-b")!!
assertEquals(2, remaining.blocks.size)
assertEquals(3, remaining.itemIdsByBlock().values.sumOf { it.size })
assertEquals(2, remaining.photos.size)
assertEquals(2, remaining.signatures.size)
assertEquals(2, countRows("block_states"))
assertEquals(3, countRows("check_item_states"))
}
@Test
fun block_loeschen_entfernt_seine_optionen_per_cascade_aber_nicht_die_anderer_bloecke() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
execSql("DELETE FROM block_states WHERE id = 'a-b1'")
assertEquals(listOf("a-c3"), checkItemStateDao.getForBlockState("a-b2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("a-b1").isEmpty())
assertEquals(1, countRows("check_item_states"))
}
@Test
fun patient_mit_dokumentation_kann_nicht_geloescht_werden_und_kinder_bleiben_erhalten() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val failed = runCatching { patientDao.delete(patientDao.getById("p-1")!!) }
assertTrue(failed.isFailure)
assertNotNull(documentationDao.getFullDocumentation("d-a"))
assertEquals(3, countRows("check_item_states"))
}
}
@@ -0,0 +1,232 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.patientEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [PatientDao]. */
class PatientDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_zurueck() = runDbTest {
val entity = patientEntity(phone2 = "0987 654321", phone3 = "0555 123")
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun optionale_telefonnummern_bleiben_null() = runDbTest {
patientDao.insert(patientEntity(phone2 = null, phone3 = null))
val loaded = patientDao.getById("p-1")!!
assertNull(loaded.phone2)
assertNull(loaded.phone3)
}
@Test
fun umlaute_und_sonderzeichen_bleiben_unveraendert() = runDbTest {
val entity = patientEntity(
firstName = "Jörg-Åke", lastName = "O'Brien-Müller", street = "Straße \"des\" 17. Juni 1/2", city = "Köln-Ehrenfeld",
)
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(patientDao.getById("unbekannt"))
}
@Test
fun insert_mit_vorhandener_id_wirft_und_laesst_den_bestehenden_datensatz_unveraendert() = runDbTest {
val original = patientEntity(lastName = "Original")
patientDao.insert(original)
expectThrows<SQLiteConstraintException> { patientDao.insert(patientEntity(lastName = "Duplikat")) }
assertEquals(original, patientDao.getById("p-1"))
}
@Test
fun doppelte_stammdaten_mit_unterschiedlicher_id_sind_auf_db_ebene_erlaubt() = runDbTest {
// Duplikatschutz liegt bewusst in der Anwendung (findExisting), nicht in einem UNIQUE-Index.
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
assertEquals(2, countRows("patients"))
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_ueberschreibt_die_felder_des_datensatzes() = runDbTest {
patientDao.insert(patientEntity())
val changed = patientEntity(lastName = "Musterfrau", city = "Neustadt", phone2 = "0111", updatedAt = 2_000L)
patientDao.update(changed)
assertEquals(changed, patientDao.getById("p-1"))
}
@Test
fun update_einer_unbekannten_id_legt_keinen_datensatz_an() = runDbTest {
patientDao.update(patientEntity(id = "geist"))
assertNull(patientDao.getById("geist"))
assertEquals(0, countRows("patients"))
}
@Test
fun delete_entfernt_nur_den_uebergebenen_patienten() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
patientDao.delete(patientEntity(id = "p-1"))
assertNull(patientDao.getById("p-1"))
assertNotNull(patientDao.getById("p-2"))
}
@Test
fun delete_eines_unbekannten_patienten_ist_wirkungslos() = runDbTest {
patientDao.insert(patientEntity())
patientDao.delete(patientEntity(id = "unbekannt"))
assertEquals(1, countRows("patients"))
}
@Test
fun delete_wird_durch_vorhandene_dokumentation_verhindert_restrict() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> { patientDao.delete(patientEntity()) }
assertNotNull(patientDao.getById("p-1"))
assertEquals(1, countRows("documentations"))
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_bei_gleichem_vornamen_nachnamen_und_geburtsdatum() = runDbTest {
patientDao.insert(patientEntity())
assertEquals("p-1", patientDao.findExisting("Max", "Mustermann", "1980-03-14")?.id)
}
@Test
fun findExisting_trifft_nicht_bei_abweichendem_feld() = runDbTest {
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("Moritz", "Mustermann", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Musterfrau", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Mustermann", "1980-03-15"))
}
@Test
fun findExisting_ist_ein_exakter_abgleich_und_unterscheidet_gross_und_kleinschreibung() = runDbTest {
// Dokumentiert das aktuelle Verhalten (SQL "="): "max" findet "Max" NICHT.
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("max", "mustermann", "1980-03-14"))
}
@Test
fun findExisting_liefert_bei_mehreren_treffern_genau_einen() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
val found = patientDao.findExisting("Max", "Mustermann", "1980-03-14")
assertTrue(found?.id == "p-1" || found?.id == "p-2")
}
// --- hasDocumentations ---------------------------------------------------------------
@Test
fun hasDocumentations_ist_false_ohne_und_true_mit_dokumentation() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1")
assertTrue(patientDao.hasDocumentations("p-1"))
assertFalse(patientDao.hasDocumentations("p-2"))
assertFalse(patientDao.hasDocumentations("unbekannt"))
}
// --- observeAllWithLastDocumentation -------------------------------------------------
@Test
fun observeAllWithLastDocumentation_sortiert_nach_nachname_dann_vorname() = runDbTest {
seedPatient("p-1", firstName = "Max", lastName = "Müller")
seedPatient("p-2", firstName = "Anna", lastName = "Müller")
seedPatient("p-3", firstName = "Zoe", lastName = "Adler")
val result = patientDao.observeAllWithLastDocumentation().first()
assertEquals(listOf("p-3", "p-2", "p-1"), result.map { it.patient.id })
}
@Test
fun lastDocumentationAt_ist_das_maximale_erstelldatum_unabhaengig_von_der_einfuegereihenfolge() = runDbTest {
seedPatient("p-1")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-2", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-3", patientId = "p-1", createdAt = 2_000L)
val result = patientDao.observeAllWithLastDocumentation().first().single()
assertEquals(3_000L, result.lastDocumentationAt)
}
@Test
fun lastDocumentationAt_ist_null_ohne_dokumentation_und_beruecksichtigt_nur_eigene_dokumentationen() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 5_000L)
val byId = patientDao.observeAllWithLastDocumentation().first().associateBy { it.patient.id }
assertEquals(5_000L, byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun observeAllWithLastDocumentation_liefert_leere_liste_ohne_patienten() = runDbTest {
assertTrue(patientDao.observeAllWithLastDocumentation().first().isEmpty())
}
@Test
fun observeAllWithLastDocumentation_aktualisiert_sich_bei_aenderungen() = runDbTest {
seedPatient("p-1")
val waiting = async {
patientDao.observeAllWithLastDocumentation().awaitMatching { list ->
list.size == 2 && list.any { it.patient.id == "p-1" && it.lastDocumentationAt == 9_000L }
}
}
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 9_000L)
assertEquals(2, waiting.await().size)
}
}
@@ -0,0 +1,168 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [PhotoDao] und [SignatureDao] (beide nach demselben Muster aufgebaut). */
class PhotoAndSignatureDaoTest : RoomTestBase() {
// --- PhotoDao ------------------------------------------------------------------------
@Test
fun photo_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val photo = photoEntity(fileName = "wunde_2026-08-23.jpg", orderIndex = 2, createdAt = 5_000L)
photoDao.insert(photo)
assertEquals(photo, photoDao.getById("ph-1"))
assertNull(photoDao.getById("unbekannt"))
}
@Test
fun photo_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-1", orderIndex = 2))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("ph-1", "ph-2", "ph-3"), photoDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("ph-x"), photoDao.getForDocumentation("d-2").map { it.id })
assertTrue(photoDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun photo_getMaxOrderIndex_ist_minus_eins_ohne_fotos_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, photoDao.getMaxOrderIndex("d-1"))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 4))
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-2", orderIndex = 9))
assertEquals(4, photoDao.getMaxOrderIndex("d-1"))
assertEquals(9, photoDao.getMaxOrderIndex("d-2"))
assertEquals(-1, photoDao.getMaxOrderIndex("unbekannt"))
}
@Test
fun photo_getMaxOrderIndex_bleibt_nach_dem_loeschen_eines_mittleren_fotos_korrekt() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-3", orderIndex = 2))
photoDao.delete(photoEntity(id = "ph-2", orderIndex = 1))
assertEquals(2, photoDao.getMaxOrderIndex("d-1"))
assertEquals(listOf(0, 2), photoDao.getForDocumentation("d-1").map { it.orderIndex })
}
@Test
fun photo_delete_entfernt_nur_das_angegebene_foto() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.delete(photoEntity(id = "ph-1"))
assertNull(photoDao.getById("ph-1"))
assertEquals("ph-2", photoDao.getById("ph-2")?.id)
}
@Test
fun photo_mit_gleichem_orderIndex_ist_erlaubt_kein_unique_index() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 0))
assertEquals(2, photoDao.getForDocumentation("d-1").size)
}
@Test
fun photo_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1"))
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-1", fileName = "andere.jpg")) }
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("photos"))
}
// --- SignatureDao --------------------------------------------------------------------
@Test
fun signature_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val signature = signatureEntity(signerName = "Dr. Åsa Müller-Öz", fileName = "sig_1.png", orderIndex = 1, createdAt = 7_000L)
signatureDao.insert(signature)
assertEquals(signature, signatureDao.getById("s-1"))
assertNull(signatureDao.getById("unbekannt"))
}
@Test
fun signature_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-1", orderIndex = 2))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 1))
signatureDao.insert(signatureEntity(id = "s-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("s-1", "s-2", "s-3"), signatureDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("s-x"), signatureDao.getForDocumentation("d-2").map { it.id })
assertTrue(signatureDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun signature_getMaxOrderIndex_ist_minus_eins_ohne_signaturen_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, signatureDao.getMaxOrderIndex("d-1"))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 3))
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-2", orderIndex = 7))
assertEquals(3, signatureDao.getMaxOrderIndex("d-1"))
assertEquals(7, signatureDao.getMaxOrderIndex("d-2"))
}
@Test
fun signature_delete_entfernt_nur_die_angegebene_signatur() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", orderIndex = 1))
signatureDao.delete(signatureEntity(id = "s-1"))
assertNull(signatureDao.getById("s-1"))
assertEquals("s-2", signatureDao.getById("s-2")?.id)
}
@Test
fun signature_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1"))
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-1", signerName = "Anderer")) }
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("signatures"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Suchtests (QA-002): [DocumentationDao.search] und [PatientDao.searchWithLastDocumentation].
*
* Szenario (Erstelldatum in Klammern):
* - p-1 Max Mustermann, 1980-03-14: d-1 (1000), d-2 (3000)
* - p-2 Erika Musterfrau, 1975-12-01: d-3 (2000)
* - p-3 Hans Beispiel, 1990-07-30: d-4 (500)
* - p-4 Zora Zimmer, 2001-01-01: keine Dokumentation
*/
class SearchQueryTest : RoomTestBase() {
private suspend fun seedDoc(id: String, patientId: String, createdAt: Long, first: String, last: String, birth: String) {
documentationDao.insert(
documentationEntity(
id = id, patientId = patientId, createdAt = createdAt,
firstNameSnapshot = first, lastNameSnapshot = last, birthDateSnapshot = birth,
)
)
}
private suspend fun seedScenario() {
seedPatient("p-1", "Max", "Mustermann", "1980-03-14")
seedPatient("p-2", "Erika", "Musterfrau", "1975-12-01")
seedPatient("p-3", "Hans", "Beispiel", "1990-07-30")
seedPatient("p-4", "Zora", "Zimmer", "2001-01-01")
seedDoc("d-1", "p-1", 1_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-2", "p-1", 3_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-3", "p-2", 2_000L, "Erika", "Musterfrau", "1975-12-01")
seedDoc("d-4", "p-3", 500L, "Hans", "Beispiel", "1990-07-30")
}
private suspend fun searchDocs(query: String) = documentationDao.search(query).first().map { it.id }
// --- Dokumentationssuche -------------------------------------------------------------
@Test
fun dokumentationssuche_findet_ueber_den_nachnamen_neueste_zuerst() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1"), searchDocs("Muster"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Mustermann"))
}
@Test
fun dokumentationssuche_findet_ueber_den_vornamen_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
seedScenario()
assertEquals(listOf("d-3"), searchDocs("erika"))
assertEquals(listOf("d-3"), searchDocs("ERIKA"))
}
@Test
fun dokumentationssuche_findet_ueber_das_geburtsdatum_auch_teilweise() = runDbTest {
seedScenario()
assertEquals(listOf("d-4"), searchDocs("1990-07"))
assertEquals(listOf("d-2", "d-1"), searchDocs("1980"))
assertEquals(listOf("d-3"), searchDocs("12-01"))
}
@Test
fun dokumentationssuche_ist_ein_teilstring_abgleich() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-1"), searchDocs("term"))
}
@Test
fun dokumentationssuche_ohne_treffer_liefert_leere_liste() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zzz").isEmpty())
}
@Test
fun dokumentationssuche_liefert_keine_dubletten_wenn_mehrere_spalten_treffen() = runDbTest {
seedScenario()
// "e" trifft bei p-2 gleichzeitig Vor- und Nachnamen (Erika / Musterfrau).
val ids = searchDocs("e")
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), ids)
assertEquals(ids.size, ids.toSet().size)
}
@Test
fun dokumentationssuche_findet_keine_patienten_ohne_dokumentation() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zora").isEmpty())
}
@Test
fun dokumentationssuche_mit_leerem_text_liefert_alle_dokumentationen_der_schutz_davor_liegt_im_repository() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), searchDocs(""))
}
@Test
fun dokumentationssuche_sucht_gegen_den_aktuellen_namen_und_nicht_gegen_den_snapshot() = runDbTest {
seedScenario()
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Neuname"))
assertTrue(searchDocs("Mustermann").isEmpty())
// Die gelieferten Zeilen zeigen weiterhin den eingefrorenen Namen.
val hits = documentationDao.search("Neuname").first()
assertTrue(hits.all { it.patientLastNameSnapshot == "Mustermann" })
}
@Test
fun dokumentationssuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchDocs("'; DROP TABLE patients; --").isEmpty())
assertTrue(searchDocs("\"").isEmpty())
assertTrue(searchDocs("O'Brien").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun dokumentationssuche_aktualisiert_sich_bei_neuen_dokumentationen_und_namensaenderungen() = runDbTest {
seedScenario()
val afterNewDoc = async { documentationDao.search("Muster").awaitMatching { it.size == 4 } }
seedDoc("d-5", "p-1", 4_000L, "Max", "Mustermann", "1980-03-14")
assertEquals("d-5", afterNewDoc.await().first().id)
val afterRename = async { documentationDao.search("Neuname").awaitMatching { it.size == 3 } }
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-5", "d-2", "d-1"), afterRename.await().map { it.id })
}
// --- Patientensuche ------------------------------------------------------------------
private suspend fun searchPatients(query: String) = patientDao.searchWithLastDocumentation(query).first()
@Test
fun patientensuche_sortiert_treffer_nach_nachname_und_liefert_letztes_dokumentationsdatum() = runDbTest {
seedScenario()
val result = searchPatients("Muster")
assertEquals(listOf("p-2", "p-1"), result.map { it.patient.id }) // Musterfrau vor Mustermann
assertEquals(listOf(2_000L, 3_000L), result.map { it.lastDocumentationAt })
}
@Test
fun patientensuche_findet_ueber_vorname_nachname_und_geburtsdatum() = runDbTest {
seedScenario()
assertEquals(listOf("p-3"), searchPatients("han").map { it.patient.id })
assertEquals(listOf("p-3"), searchPatients("beispiel").map { it.patient.id })
assertEquals(listOf("p-2"), searchPatients("1975").map { it.patient.id })
assertTrue(searchPatients("Zzz").isEmpty())
}
@Test
fun patientensuche_findet_auch_patienten_ohne_dokumentation_mit_null_datum() = runDbTest {
seedScenario()
val result = searchPatients("Zora").single()
assertEquals("p-4", result.patient.id)
assertNull(result.lastDocumentationAt)
}
@Test
fun patientensuche_liefert_keine_dubletten_trotz_mehrerer_dokumentationen_und_treffer_spalten() = runDbTest {
seedScenario()
val ids = searchPatients("e").map { it.patient.id }
assertEquals(ids.size, ids.toSet().size)
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), ids) // Beispiel, Musterfrau, Mustermann, Zimmer
}
@Test
fun patientensuche_mit_leerem_text_liefert_alle_patienten_sortiert() = runDbTest {
seedScenario()
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), searchPatients("").map { it.patient.id })
}
@Test
fun patientensuche_sortiert_bei_gleichem_nachnamen_nach_vornamen() = runDbTest {
seedPatient("p-1", "Max", "Müller", "1980-01-01")
seedPatient("p-2", "Anna", "Müller", "1981-01-01")
assertEquals(listOf("p-2", "p-1"), searchPatients("Müller").map { it.patient.id })
}
@Test
fun patientensuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchPatients("'; DROP TABLE patients; --").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun patientensuche_aktualisiert_sich_bei_neuen_patienten_und_dokumentationen() = runDbTest {
seedScenario()
val afterNewPatient = async { patientDao.searchWithLastDocumentation("Muster").awaitMatching { it.size == 3 } }
seedPatient("p-5", "Karl", "Musterkind", "2010-05-05")
assertEquals(3, afterNewPatient.await().size)
val afterNewDoc = async {
patientDao.searchWithLastDocumentation("Musterkind").awaitMatching { list -> list.singleOrNull()?.lastDocumentationAt == 8_000L }
}
seedDoc("d-9", "p-5", 8_000L, "Karl", "Musterkind", "2010-05-05")
afterNewDoc.await()
}
}
@@ -0,0 +1,128 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.woundCarerEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [WoundCarerDao] (Wundversorger-Register). */
class WoundCarerDaoTest : RoomTestBase() {
// --- insertOrUpdate ------------------------------------------------------------------
@Test
fun insertOrUpdate_legt_neuen_eintrag_an() = runDbTest {
val carer = woundCarerEntity(updatedAt = 5_000L)
woundCarerDao.insertOrUpdate(carer)
assertEquals(carer, woundCarerDao.getMostRecentlyUsed())
}
@Test
fun insertOrUpdate_ersetzt_vorhandenen_eintrag_mit_gleicher_id_vollstaendig() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", signatureFileName = "alt.png", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt-Neu", signatureFileName = "neu.png", updatedAt = 2_000L))
assertEquals(1, countRows("wound_carers"))
val loaded = woundCarerDao.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt-Neu", loaded.name)
assertEquals("neu.png", loaded.signatureFileName)
assertEquals(2_000L, loaded.updatedAt)
}
@Test
fun insertOrUpdate_mit_verschiedenen_ids_und_gleichem_namen_legt_zwei_eintraege_an() = runDbTest {
// Namens-Eindeutigkeit stellt das Repository (findByName) sicher, nicht die Datenbank.
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna"))
assertEquals(2, countRows("wound_carers"))
}
// --- findByName ----------------------------------------------------------------------
@Test
fun findByName_ist_ein_exakter_abgleich_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt"))
assertEquals("w-1", woundCarerDao.findByName("Anna Schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("anna schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("ANNA SCHMIDT")?.id)
}
@Test
fun findByName_trifft_nicht_bei_teilstring_oder_praefix() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertNull(woundCarerDao.findByName("Anna"))
assertNull(woundCarerDao.findByName("Schmidt"))
assertNull(woundCarerDao.findByName("Anna Schmidt "))
assertNull(woundCarerDao.findByName("unbekannt"))
}
// --- searchByNamePrefix --------------------------------------------------------------
@Test
fun searchByNamePrefix_liefert_praefix_treffer_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Andreas Müller", updatedAt = 2_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Bernd Beispiel", updatedAt = 3_000L))
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("an", 10).map { it.id })
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("AN", 10).map { it.id })
assertEquals(listOf("w-3"), woundCarerDao.searchByNamePrefix("Bern", 10).map { it.id })
}
@Test
fun searchByNamePrefix_ist_kein_teilstring_abgleich() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertTrue(woundCarerDao.searchByNamePrefix("Schmidt", 10).isEmpty())
assertTrue(woundCarerDao.searchByNamePrefix("nna", 10).isEmpty())
}
@Test
fun searchByNamePrefix_sortiert_nach_letzter_verwendung_absteigend_und_beachtet_das_limit() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna A", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna B", updatedAt = 4_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Anna C", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-4", name = "Anna D", updatedAt = 2_000L))
assertEquals(listOf("w-2", "w-3", "w-4", "w-1"), woundCarerDao.searchByNamePrefix("Anna", 10).map { it.id })
assertEquals(listOf("w-2", "w-3"), woundCarerDao.searchByNamePrefix("Anna", 2).map { it.id })
assertTrue(woundCarerDao.searchByNamePrefix("Anna", 0).isEmpty())
}
@Test
fun searchByNamePrefix_mit_leerem_praefix_liefert_alle_eintraege_der_schutz_davor_liegt_im_repository() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 2_000L))
assertEquals(2, woundCarerDao.searchByNamePrefix("", 10).size)
}
// --- getMostRecentlyUsed -------------------------------------------------------------
@Test
fun getMostRecentlyUsed_ist_null_bei_leerer_tabelle() = runDbTest {
assertNull(woundCarerDao.getMostRecentlyUsed())
}
@Test
fun getMostRecentlyUsed_liefert_den_eintrag_mit_dem_hoechsten_updatedAt() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Clara", updatedAt = 2_000L))
assertEquals("w-2", woundCarerDao.getMostRecentlyUsed()?.id)
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 9_000L))
assertEquals("w-1", woundCarerDao.getMostRecentlyUsed()?.id)
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.database
import android.database.sqlite.SQLiteConstraintException
import androidx.room.withTransaction
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Transaction-Tests (QA-002): Room-Transaktionen (`db.withTransaction`) müssen Mehrtabellen-
* Schreibvorgänge - wie in DocumentationRepository.createEmpty()/continueDocumentation() -
* atomar machen.
*
* Hinweis: In-Memory-Datenbanken haben nur eine Verbindung. Deshalb wird innerhalb einer
* Transaktion ausschließlich über DAOs gelesen, nie über Roh-SQL des Test-Threads.
*/
class DatabaseTransactionTest : RoomTestBase() {
// --- Commit --------------------------------------------------------------------------
@Test
fun commit_macht_alle_schreibvorgaenge_sichtbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
}
assertEquals(1, countRows("documentations"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun transaktion_gibt_das_ergebnis_des_blocks_zurueck() = runDbTest {
val result = db.withTransaction {
seedPatient()
42
}
assertEquals(42, result)
assertNotNull(patientDao.getById("p-1"))
}
@Test
fun innerhalb_der_transaktion_sind_die_eigenen_schreibvorgaenge_bereits_lesbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
assertNotNull(documentationDao.getById("d-1"))
blockStateDao.insertAll(listOf(blockStateEntity()))
assertEquals(1, blockStateDao.getForDocumentation("d-1").size)
}
}
// --- Rollback ------------------------------------------------------------------------
@Test
fun exception_im_block_verwirft_alle_schreibvorgaenge_und_wird_weitergereicht() = runDbTest {
seedPatient()
val error = expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
error("Abbruch mitten in der Transaktion")
}
}
assertEquals("Abbruch mitten in der Transaktion", error.message)
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertNotNull("Daten vor der Transaktion bleiben bestehen", patientDao.getById("p-1"))
}
@Test
fun constraint_verletzung_in_einem_spaeteren_schritt_verwirft_auch_die_frueheren_inserts() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
photoDao.insert(photoEntity(documentationId = "gibt-es-nicht")) // FK-Verletzung
}
}
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("photos"))
}
@Test
fun insertAll_ist_atomar_ein_fehlerhafter_eintrag_verwirft_die_ganze_liste() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", blockDefinitionId = "groesse"),
blockStateEntity(id = "b-3", blockDefinitionId = "wundart"), // verletzt UNIQUE
)
)
}
assertEquals(0, countRows("block_states"))
}
@Test
fun rollback_nimmt_ein_cascade_loeschen_wieder_zurueck() = runDbTest {
val doc = seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.delete(doc)
assertNull(documentationDao.getById("d-1"))
error("Abbruch nach dem Löschen")
}
}
assertNotNull(documentationDao.getById("d-1"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun datenbank_bleibt_nach_einer_fehlgeschlagenen_transaktion_voll_nutzbar() = runDbTest {
seedPatient()
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-verloren"))
error("Abbruch")
}
}
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-ok")) }
documentationDao.insert(documentationEntity(id = "d-direkt"))
assertNull(documentationDao.getById("d-verloren"))
assertNotNull(documentationDao.getById("d-ok"))
assertNotNull(documentationDao.getById("d-direkt"))
}
// --- Verschachtelung -----------------------------------------------------------------
@Test
fun verschachtelte_transaktionen_commiten_gemeinsam() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-innen")) }
}
assertEquals(2, countRows("documentations"))
}
@Test
fun fehler_in_der_inneren_transaktion_rollt_auch_die_aeussere_zurueck_selbst_wenn_er_gefangen_wird() = runDbTest {
// SQLite/Android: Wird eine verschachtelte Transaktion nicht als erfolgreich beendet,
// wird die äußere ebenfalls zurückgerollt (SQLiteDatabase.beginTransaction-Doku).
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
try {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-innen"))
error("Fehler innen")
}
} catch (e: IllegalStateException) {
// bewusst gefangen: "e" ist unbenutzt, der Fehler soll die äußere Transaktion nicht abbrechen
}
}
assertEquals(0, countRows("documentations"))
}
// --- Nebenläufigkeit -----------------------------------------------------------------
@Test
fun parallele_transaktionen_werden_serialisiert_und_verlieren_keine_schreibvorgaenge() = runDbTest {
seedDocumentation()
coroutineScope {
listOf("a", "b", "c").map { prefix ->
async(Dispatchers.Default) {
repeat(20) { i ->
db.withTransaction {
photoDao.insert(photoEntity(id = "$prefix-$i", fileName = "$prefix$i.jpg", orderIndex = i))
}
}
}
}.awaitAll()
}
assertEquals(60, countRows("photos"))
assertEquals(60, photoDao.getForDocumentation("d-1").size)
}
}
@@ -0,0 +1,378 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.DuplicateCall
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.time.LocalDate
/**
* Fortsetzen-Tests: "Dokumentation fortsetzen" erzeugt aus dem Stand einer vorhandenen
* Dokumentation eine NEUE; die Quelle bleibt unverändert.
*/
@RunWith(AndroidJUnit4::class)
class ContinueDocumentationTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** Alle Items eines Dokuments als Map definitionId (inkl. Blockbezug) -> Item. */
private fun Documentation.itemsByKey(): Map<String, CheckItem> =
blocks.flatMap { block -> block.items.map { "${block.definitionId}/${it.definitionId}" to it } }.toMap()
/** Bereitet eine Quelle mit einigen Eingaben vor und liefert sie frisch geladen zurück. */
private suspend fun prepareSourceWithInput(): Documentation {
env.insertPatient()
val created = repo.createEmpty("patient-1")
val firstBlock = created.blocks.first()
repo.setChecked(firstBlock.firstPlainItem().checkItemStateId, true)
val freeText = firstBlock.items.last()
repo.setChecked(freeText.checkItemStateId, true)
repo.updateFreeText(freeText.checkItemStateId, "Rötung am Wundrand")
return repo.getFullDocumentation(created.id)!!
}
// --- Inhalt wird übernommen ----------------------------------------------------------
@Test
fun fortsetzen_uebernimmt_bloecke_und_optionen_inklusive_haken_und_freitext() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertEquals(source.blocks.size, copy.blocks.size)
for (sourceBlock in source.blocks) {
val copyBlock = copy.blocks.single { it.definitionId == sourceBlock.definitionId }
assertEquals(sourceBlock.title, copyBlock.title)
assertEquals(sourceBlock.order, copyBlock.order)
assertEquals(sourceBlock.isEdited, copyBlock.isEdited)
}
// Option für Option: gleiche Inhalte (Label, Reihenfolge, Haken, Freitext, Hinweis, strukturierte Eingabe).
val sourceItems = source.itemsByKey()
val copyItems = copy.itemsByKey()
assertEquals(sourceItems.keys, copyItems.keys)
for ((key, sourceItem) in sourceItems) {
val copyItem = copyItems.getValue(key)
assertEquals(key, sourceItem.label, copyItem.label)
assertEquals(key, sourceItem.order, copyItem.order)
assertEquals(key, sourceItem.checked, copyItem.checked)
assertEquals(key, sourceItem.isFreeText, copyItem.isFreeText)
assertEquals(key, sourceItem.freeTextValue, copyItem.freeTextValue)
assertEquals(key, sourceItem.isStaticHint, copyItem.isStaticHint)
assertEquals(key, sourceItem.inputSpec, copyItem.inputSpec)
}
assertEquals(1, copy.blocks.count { it.isEdited })
assertEquals("Rötung am Wundrand", copy.blocks.first().items.last().freeTextValue)
}
@Test
fun fortsetzen_erzeugt_ausschliesslich_neue_ids() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertNotEquals(source.id, copy.id)
val sourceBlockIds = source.blocks.map { it.blockStateId }.toSet()
val sourceItemIds = source.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.toSet()
assertTrue(copy.blocks.none { it.blockStateId in sourceBlockIds })
assertTrue(copy.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.none { it in sourceItemIds })
}
@Test
fun fortsetzen_setzt_metadaten_der_neuen_dokumentation_korrekt() = runDbTest {
val source = prepareSourceWithInput()
env.setStatus(source.id, DocumentationStatus.COMPLETED)
val copy = repo.continueDocumentation(source.id)
assertEquals(DocumentationStatus.IN_PROGRESS, copy.status)
assertEquals(source.id, copy.continuedFromDocumentationId)
assertEquals(source.patientId, copy.patientId)
assertEquals(copy.createdAt, copy.updatedAt)
assertEquals(DocumentationStatus.COMPLETED, repo.getFullDocumentation(source.id)!!.status)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
assertTrue(env.patientRepository.hasDocumentations("patient-1"))
}
// --- Quelle bleibt unverändert / Unabhängigkeit --------------------------------------
@Test
fun fortsetzen_laesst_die_quelldokumentation_unveraendert() = runDbTest {
val source = prepareSourceWithInput()
repo.continueDocumentation(source.id)
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertEquals(source.status, sourceAfter.status)
assertEquals(source.createdAt, sourceAfter.createdAt)
assertEquals(source.updatedAt, sourceAfter.updatedAt)
assertNull(sourceAfter.continuedFromDocumentationId)
assertEquals(source.blocks, sourceAfter.blocks)
assertEquals(source.photos, sourceAfter.photos)
assertEquals(source.signatures, sourceAfter.signatures)
}
@Test
fun aenderungen_an_der_kopie_veraendern_die_quelle_nicht_und_umgekehrt() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
// Kopie: den in der Quelle gesetzten Haken entfernen und Freitext ändern.
val copyFirstBlock = copy.blocks.first()
repo.setChecked(copyFirstBlock.firstPlainItem().checkItemStateId, false)
repo.updateFreeText(copyFirstBlock.items.last().checkItemStateId, "Neuer Text")
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertTrue(sourceAfter.blocks.first().firstPlainItem().checked)
assertEquals("Rötung am Wundrand", sourceAfter.blocks.first().items.last().freeTextValue)
// Quelle: weiteren Haken in einem anderen Block setzen.
val otherSourceBlock = source.blocks[1]
repo.setChecked(otherSourceBlock.firstPlainItem().checkItemStateId, true)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertFalse(copyAfter.blocks.single { it.definitionId == otherSourceBlock.definitionId }.isEdited)
assertEquals("Neuer Text", copyAfter.blocks.first().items.last().freeTextValue)
}
// --- Patienten-Snapshot --------------------------------------------------------------
@Test
fun fortsetzen_bildet_den_patienten_snapshot_frisch_aus_dem_aktuellen_patienten() = runDbTest {
val source = prepareSourceWithInput()
val patientDao = env.db.patientDao()
patientDao.update(
patientDao.getById("patient-1")!!.copy(
lastName = "Musterfrau", street = "Neue Straße 7", postalCode = "99999", city = "Neustadt",
)
)
val copy = repo.continueDocumentation(source.id)
assertEquals("Musterfrau", copy.patientSnapshot.lastName)
assertEquals("Neue Straße 7", copy.patientSnapshot.street)
assertEquals("99999", copy.patientSnapshot.postalCode)
assertEquals("Neustadt", copy.patientSnapshot.city)
assertEquals(LocalDate.of(1980, 3, 14), copy.patientSnapshot.birthDate)
// Die Quelle behält ihren damaligen Stand.
val sourceSnapshot = repo.getFullDocumentation(source.id)!!.patientSnapshot
assertEquals("Mustermann", sourceSnapshot.lastName)
assertEquals("Musterstraße 1", sourceSnapshot.street)
assertEquals("Musterstadt", sourceSnapshot.city)
}
// --- Historische Snapshots / Katalogversion ------------------------------------------
@Test
fun fortsetzen_bewahrt_historische_titel_labels_und_katalogversion() = runDbTest {
env.insertPatient()
val now = 5_000L
val historicDocId = "historic-doc"
env.db.documentationDao().insert(
DocumentationEntity(
id = historicDocId, patientId = "patient-1", status = DocumentationStatus.COMPLETED,
createdAt = now, updatedAt = now, continuedFromDocumentationId = null, pdfLastGeneratedAt = null,
catalogVersion = 1,
patientFirstNameSnapshot = "Max", patientLastNameSnapshot = "Mustermann",
patientBirthDateSnapshot = "1980-03-14", patientStreetSnapshot = "Musterstraße 1",
patientPostalCodeSnapshot = "12345", patientCitySnapshot = "Musterstadt",
)
)
env.db.blockStateDao().insertAll(
listOf(BlockStateEntity("hist-block", historicDocId, "gibt_es_nicht_mehr", "Alter Titel", 1, now))
)
env.db.checkItemStateDao().insertAll(
listOf(
CheckItemStateEntity("hist-item", "hist-block", "alter_eintrag", "Alter Eintrag", 1, checked = true),
)
)
assertNotEquals("Voraussetzung: aktuelle Katalogversion weicht ab", 1, DocumentationCatalog.CATALOG_VERSION)
val copy = repo.continueDocumentation(historicDocId)
assertEquals(1, copy.catalogVersion)
val block = copy.blocks.single()
assertEquals("gibt_es_nicht_mehr", block.definitionId)
assertEquals("Alter Titel", block.title)
assertEquals("Alter Eintrag", block.items.single().label)
assertTrue(block.items.single().checked)
}
// --- Unterschriften ------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_keine_unterschriften() = runDbTest {
val source = prepareSourceWithInput()
repo.saveSignature(source.id, "Anna Schmidt", signatureBitmap())
val copy = repo.continueDocumentation(source.id)
assertTrue(copy.signatures.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.signatures.size)
assertEquals(1, env.signatureStorage.saved.size) // keine zusätzliche Datei angelegt
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_fotos_standardmaessig_als_eigenstaendige_dateien() = runDbTest {
val source = prepareSourceWithInput()
val p1 = repo.addPhoto(source.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(source.id, Uri.parse("content://test/2"))
val copy = repo.continueDocumentation(source.id)
assertEquals(listOf(0, 1), copy.photos.map { it.orderIndex })
assertTrue(copy.photos.none { it.id == p1.id || it.id == p2.id })
assertTrue("Dateien müssen physisch eigenständig sein", copy.photos.none { it.fileName == p1.fileName || it.fileName == p2.fileName })
assertTrue(copy.photos.all { it.documentationId == copy.id })
assertEquals(
setOf(
DuplicateCall(source.id, p1.fileName, copy.id),
DuplicateCall(source.id, p2.fileName, copy.id),
),
env.photoStorage.duplicated.toSet(),
)
assertEquals(2, env.photoStorage.duplicated.size)
// Quelle behält ihre Fotos.
assertEquals(listOf(p1.fileName, p2.fileName), repo.getFullDocumentation(source.id)!!.photos.map { it.fileName })
}
@Test
fun fortsetzen_mit_copyPhotos_false_uebernimmt_keine_fotos() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id, copyPhotos = false)
assertTrue(copy.photos.isEmpty())
assertTrue(env.photoStorage.duplicated.isEmpty())
}
@Test
fun foto_loeschen_in_der_kopie_betrifft_die_quelle_nicht() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.removePhoto(copy.photos.single().id)
assertTrue(repo.getFullDocumentation(copy.id)!!.photos.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.photos.size)
}
// --- Fehlerfälle / Atomarität --------------------------------------------------------
@Test
fun fortsetzen_einer_unbekannten_dokumentation_wirft_und_legt_nichts_an() = runDbTest {
env.insertPatient()
val error = expectThrows<IllegalStateException> { repo.continueDocumentation("unbekannt") }
assertTrue(error.message.orEmpty().contains("unbekannt"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun fortsetzen_ist_atomar_ein_fehler_beim_foto_kopieren_hinterlaesst_keine_halbe_dokumentation() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
repo.addPhoto(source.id, Uri.parse("content://test/2"))
env.photoStorage.failOnDuplicateCall = 2 // zweites Foto schlägt fehl
expectThrows<IOException> { repo.continueDocumentation(source.id) }
val all = env.db.documentationDao().observeAll().first()
assertEquals(listOf(source.id), all.map { it.id })
assertEquals(1, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun loeschen_der_quelle_laesst_die_fortgesetzte_dokumentation_bestehen() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.delete(source.id)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertNull("Verweis auf die gelöschte Quelle wird auf null gesetzt", copyAfter.continuedFromDocumentationId)
assertEquals(copy.blocks.size, copyAfter.blocks.size)
assertEquals(1, copyAfter.photos.size)
assertEquals(listOf(source.id), env.photoStorage.deletedAll) // nur der Ordner der Quelle
}
@Test
fun mehrfaches_fortsetzen_bildet_eine_kette() = runDbTest {
val first = prepareSourceWithInput()
val second = repo.continueDocumentation(first.id)
val third = repo.continueDocumentation(second.id)
assertEquals(first.id, second.continuedFromDocumentationId)
assertEquals(second.id, third.continuedFromDocumentationId)
assertEquals(1, third.blocks.count { it.isEdited })
assertEquals(3, env.db.documentationDao().countForPatient("patient-1"))
}
// --- continueLatestForPatient --------------------------------------------------------
@Test
fun continueLatestForPatient_liefert_null_wenn_der_patient_noch_keine_dokumentation_hat() = runDbTest {
env.insertPatient()
assertNull(repo.continueLatestForPatient("patient-1"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun continueLatestForPatient_setzt_die_zuletzt_erstellte_dokumentation_des_patienten_fort() = runDbTest {
env.insertPatient()
env.insertPatient(id = "patient-2", firstName = "Erika", lastName = "Musterfrau")
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
val otherPatientsDoc = repo.createEmpty("patient-2")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
env.setCreatedAt(otherPatientsDoc.id, 3_000L) // neuer, aber anderer Patient
repo.setChecked(newer.blocks.first().firstPlainItem().checkItemStateId, true)
val copy = repo.continueLatestForPatient("patient-1")!!
assertEquals(newer.id, copy.continuedFromDocumentationId)
assertEquals("patient-1", copy.patientId)
assertEquals(1, copy.blocks.count { it.isEdited })
}
}
@@ -0,0 +1,545 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
/**
* Repository-Tests für [DocumentationRepository] gegen eine echte In-Memory-Room-Datenbank.
* Dateispeicher und PDF-Erzeugung sind durch Fakes ersetzt (s. testutil/Fakes.kt).
* "Fortsetzen" wird separat in [ContinueDocumentationTest] getestet.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
// --- createEmpty ---------------------------------------------------------------------
@Test
fun createEmpty_legt_neue_dokumentation_im_status_in_bearbeitung_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals("patient-1", doc.patientId)
assertEquals(DocumentationStatus.IN_PROGRESS, doc.status)
assertNull(doc.continuedFromDocumentationId)
assertEquals(DocumentationCatalog.CATALOG_VERSION, doc.catalogVersion)
assertEquals(doc.createdAt, doc.updatedAt)
assertTrue(doc.photos.isEmpty())
assertTrue(doc.signatures.isEmpty())
assertEquals(doc.id, repo.getFullDocumentation(doc.id)?.id)
}
@Test
fun createEmpty_uebernimmt_den_patienten_snapshot() = runDbTest {
env.insertPatient(
firstName = "Erika", lastName = "Musterfrau", birthDate = "1975-12-01",
street = "Hauptstraße 5", postalCode = "54321", city = "Beispielstadt",
)
val snapshot = repo.createEmpty("patient-1").patientSnapshot
assertEquals("Erika", snapshot.firstName)
assertEquals("Musterfrau", snapshot.lastName)
assertEquals(LocalDate.of(1975, 12, 1), snapshot.birthDate)
assertEquals("Hauptstraße 5", snapshot.street)
assertEquals("54321", snapshot.postalCode)
assertEquals("Beispielstadt", snapshot.city)
}
@Test
fun createEmpty_erzeugt_pro_katalogblock_einen_block_mit_titel_und_reihenfolge_snapshot() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(DocumentationCatalog.blocks.size, doc.blocks.size)
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.title, block.title)
assertEquals(def.order, block.order)
}
}
@Test
fun createEmpty_haengt_pro_block_die_generische_freitext_option_als_letztes_element_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.items.size + 1, block.items.size)
val generic = block.items.last()
assertEquals(def.freeTextDefinitionId, generic.definitionId)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, generic.label)
assertEquals((def.items.maxOfOrNull { it.order } ?: 0) + 1, generic.order)
assertTrue(generic.isFreeText)
assertFalse(generic.isStaticHint)
assertFalse(generic.checked)
assertEquals("", generic.freeTextValue)
}
}
@Test
fun createEmpty_uebernimmt_katalogeintraege_1_zu_1_und_hinweistexte_starten_angehakt() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
for (itemDef in def.items) {
val state = block.items.single { it.definitionId == itemDef.id }
assertEquals(itemDef.label, state.label)
assertEquals(itemDef.order, state.order)
assertEquals(itemDef.isFreeText, state.isFreeText)
assertEquals(itemDef.isStaticHint, state.isStaticHint)
// Nur Hinweistexte starten angehakt, alles andere unangehakt/leer.
assertEquals(itemDef.isStaticHint, state.checked)
assertEquals("", state.freeTextValue)
val expectedSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: ""
assertEquals(expectedSpec, state.inputSpec)
}
}
}
@Test
fun createEmpty_ergibt_keinen_bearbeiteten_block_auch_nicht_durch_hinweistexte() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertTrue(doc.blocks.none { it.isEdited })
}
@Test
fun createEmpty_vergibt_fuer_jede_dokumentation_neue_ids() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
assertTrue(first.id != second.id)
val allBlockIds = (first.blocks + second.blocks).map { it.blockStateId }
assertEquals(allBlockIds.size, allBlockIds.toSet().size)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun createEmpty_mit_unbekanntem_patienten_wirft_und_legt_nichts_an() = runDbTest {
val error = expectThrows<IllegalStateException> { repo.createEmpty("gibt-es-nicht") }
assertTrue(error.message.orEmpty().contains("gibt-es-nicht"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
// --- Lesen / Liste / Suche -----------------------------------------------------------
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getFullDocumentation("unbekannt"))
}
@Test
fun observeAll_liefert_neueste_dokumentation_zuerst_mit_snapshot_namen() = runDbTest {
env.insertPatient()
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val list = repo.observeAll().first()
assertEquals(listOf(newer.id, older.id), list.map { it.id })
assertEquals("Max Mustermann", list.first().patientFullName)
assertEquals(DocumentationStatus.IN_PROGRESS, list.first().status)
}
@Test
fun search_sucht_gegen_den_aktuellen_patientennamen_zeigt_aber_den_snapshot_namen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau"))
val found = repo.search("Musterfrau").first()
assertEquals(listOf(doc.id), found.map { it.id })
assertEquals("Max Mustermann", found.single().patientFullName)
assertTrue(repo.search("Mustermann").first().isEmpty())
}
@Test
fun search_findet_auch_ueber_vornamen_und_geburtsdatum() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(listOf(doc.id), repo.search("Ma").first().map { it.id })
assertEquals(listOf(doc.id), repo.search("1980-03").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
// --- Autosave / Blockstatus ----------------------------------------------------------
@Test
fun setChecked_persistiert_und_macht_den_block_bearbeitet() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val reloaded = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = reloaded.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.single { it.checkItemStateId == item.checkItemStateId }.checked)
assertTrue(reloadedBlock.isEdited)
assertEquals(1, reloaded.blocks.count { it.isEdited })
}
@Test
fun setChecked_false_nimmt_den_bearbeitet_status_wieder_zurueck() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
repo.setChecked(item.checkItemStateId, false)
val reloaded = repo.getFullDocumentation(doc.id)!!
assertTrue(reloaded.blocks.none { it.isEdited })
}
@Test
fun freitext_wird_gespeichert_und_zaehlt_erst_mit_haken_als_bearbeitung() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val freeText = block.items.last()
val text = "Größe 3,5 × 2 cm\nzweite Zeile – Rötung"
repo.updateFreeText(freeText.checkItemStateId, text)
val textOnly = repo.getFullDocumentation(doc.id)!!
assertEquals(text, textOnly.blocks.single { it.blockStateId == block.blockStateId }.items.last().freeTextValue)
assertTrue("Freitext ohne Haken darf den Block nicht bearbeitet machen", textOnly.blocks.none { it.isEdited })
repo.setChecked(freeText.checkItemStateId, true)
val checked = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = checked.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.last().checked)
assertEquals(text, reloadedBlock.items.last().freeTextValue)
assertTrue(reloadedBlock.isEdited)
}
@Test
fun aenderungen_in_einer_dokumentation_beruehren_eine_andere_nicht() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
repo.setChecked(first.blocks.first().firstPlainItem().checkItemStateId, true)
assertTrue(repo.getFullDocumentation(second.id)!!.blocks.none { it.isEdited })
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun addPhoto_vergibt_fortlaufenden_orderIndex_und_speichert_den_storage_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val uri1 = Uri.parse("content://test/1")
val uri2 = Uri.parse("content://test/2")
val p1 = repo.addPhoto(doc.id, uri1)
val p2 = repo.addPhoto(doc.id, uri2)
assertEquals(0, p1.orderIndex)
assertEquals(1, p2.orderIndex)
assertEquals(listOf(doc.id to uri1, doc.id to uri2), env.photoStorage.imported)
val photos = repo.getFullDocumentation(doc.id)!!.photos
assertEquals(listOf(p1.fileName, p2.fileName), photos.map { it.fileName })
assertEquals(listOf(0, 1), photos.map { it.orderIndex })
}
@Test
fun removePhoto_loescht_db_zeile_und_datei_und_vergibt_orderIndex_nicht_neu() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val p1 = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(doc.id, Uri.parse("content://test/2"))
repo.removePhoto(p1.id)
assertEquals(listOf(p2.id), repo.getFullDocumentation(doc.id)!!.photos.map { it.id })
assertEquals(listOf(doc.id to p1.fileName), env.photoStorage.deleted)
val p3 = repo.addPhoto(doc.id, Uri.parse("content://test/3"))
assertEquals(2, p3.orderIndex)
}
@Test
fun removePhoto_mit_unbekannter_id_tut_nichts() = runDbTest {
env.insertPatient()
repo.createEmpty("patient-1")
repo.removePhoto("unbekannt")
assertTrue(env.photoStorage.deleted.isEmpty())
}
@Test
fun removePhoto_entfernt_die_db_zeile_auch_wenn_die_datei_nicht_geloescht_werden_kann() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val photo = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
env.photoStorage.failOnDelete = true
repo.removePhoto(photo.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.photos.isEmpty())
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_trimmt_den_namen_und_vergibt_fortlaufenden_orderIndex() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val first = repo.saveSignature(doc.id, " Anna Schmidt ", signatureBitmap())
val second = repo.saveSignature(doc.id, "Bernd Beispiel", signatureBitmap())
assertEquals("Anna Schmidt", first.signerName)
assertEquals(0, first.orderIndex)
assertEquals(1, second.orderIndex)
val signatures = repo.getFullDocumentation(doc.id)!!.signatures
assertEquals(listOf("Anna Schmidt", "Bernd Beispiel"), signatures.map { it.signerName })
assertEquals(listOf(first.fileName, second.fileName), signatures.map { it.fileName })
assertEquals(listOf(doc.id to first.fileName, doc.id to second.fileName), env.signatureStorage.saved)
}
@Test
fun saveSignature_aktualisiert_das_wundversorger_register() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertEquals(1, env.woundCarerSignatureStorage.saved.size)
}
@Test
fun saveSignature_mit_leerem_namen_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, " ", signatureBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_mit_leerer_zeichnung_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, "Anna Schmidt", blankBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun removeSignature_loescht_db_zeile_und_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
assertEquals(listOf(doc.id to signature.fileName), env.signatureStorage.deleted)
}
@Test
fun removeSignature_mit_unbekannter_id_tut_nichts_und_toleriert_storage_fehler() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.removeSignature("unbekannt")
assertTrue(env.signatureStorage.deleted.isEmpty())
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
env.signatureStorage.failOnDelete = true
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun delete_entfernt_dokumentation_samt_bloecken_fotos_und_signaturen_sowie_dateiordner() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.addPhoto(doc.id, Uri.parse("content://test/1"))
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
val blockStateIds = doc.blocks.map { it.blockStateId }
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
assertTrue(env.db.blockStateDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.photoDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
for (blockStateId in blockStateIds) {
assertTrue(env.db.checkItemStateDao().getForBlockState(blockStateId).isEmpty())
}
assertEquals(listOf(doc.id), env.photoStorage.deletedAll)
assertEquals(listOf(doc.id), env.signatureStorage.deletedAll)
assertNotNull("Der Patient darf nicht mitgelöscht werden", env.patientRepository.getById("patient-1"))
}
@Test
fun delete_beruehrt_das_wundversorger_register_nicht() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.delete(doc.id)
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertTrue(env.woundCarerSignatureStorage.deletedAll.isEmpty())
assertTrue(env.woundCarerSignatureStorage.deleted.isEmpty())
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.delete("unbekannt")
assertTrue(env.photoStorage.deletedAll.isEmpty())
assertTrue(env.signatureStorage.deletedAll.isEmpty())
}
@Test
fun delete_entfernt_die_dokumentation_auch_wenn_dateiordner_nicht_geloescht_werden_koennen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
env.photoStorage.failOnDeleteAll = true
env.signatureStorage.failOnDeleteAll = true
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun exportPdf_fuer_unbekannte_dokumentation_liefert_fachlichen_fehler_ohne_exporter_aufruf() = runDbTest {
val result = repo.exportPdf("unbekannt")
assertEquals(PdfExportResult.Failure(PdfExportError.DocumentationNotFound), result)
assertTrue(env.pdfExporter.models.isEmpty())
}
@Test
fun exportPdf_uebergibt_nur_bearbeitete_bloecke_und_den_schemakonformen_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val result = repo.exportPdf(doc.id)
assertTrue(result is PdfExportResult.Success)
val model = env.pdfExporter.models.single()
assertEquals(doc.id, model.documentationId)
assertEquals(listOf(block.title), model.blocks.map { it.title })
assertTrue(model.blocks.single().items.any { it.label == item.label })
val createdDate = doc.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
assertEquals("${createdDate}_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
@Test
fun exportPdf_verwendet_den_snapshot_und_nicht_den_aktuellen_patientendatensatz() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau", city = "Neustadt"))
repo.exportPdf(doc.id)
val model = env.pdfExporter.models.single()
assertEquals("Max Mustermann", model.patientFullName)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
}
@Test
fun exportPdf_reicht_das_exporter_ergebnis_unveraendert_durch() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val failure = PdfExportResult.Failure(PdfExportError.WriteFailed("Speicher voll"))
env.pdfExporter.result = { failure }
assertEquals(failure, repo.exportPdf(doc.id))
}
@Test
fun convertPdfToImages_delegiert_an_den_converter() = runDbTest {
val pdf = File("/fake/pdfs/test.pdf")
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.FileNotFound
val result = repo.convertPdfToImages("doc-1", pdf)
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
assertEquals(listOf("doc-1" to pdf), env.pdfToImagesConverter.calls)
}
}
@@ -0,0 +1,223 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteConstraintException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
/** Repository-Tests für [PatientRepository] gegen eine echte In-Memory-Room-Datenbank. */
@RunWith(AndroidJUnit4::class)
class PatientRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.patientRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun patient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: LocalDate = LocalDate.of(1980, 3, 14),
) = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
phone1 = "0123 456789",
phone2 = "0987 654321",
phone3 = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
// --- upsert / getById ----------------------------------------------------------------
@Test
fun upsert_legt_neuen_patienten_an_und_setzt_zeitstempel_zentral() = runDbTest {
val before = Instant.now().toEpochMilli()
val saved = repo.upsert(patient())
assertEquals(saved.createdAt, saved.updatedAt)
assertTrue(saved.createdAt.toEpochMilli() >= before)
val loaded = repo.getById("p-1")!!
assertEquals("Max", loaded.firstName)
assertEquals("Mustermann", loaded.lastName)
assertEquals(LocalDate.of(1980, 3, 14), loaded.birthDate)
assertEquals("Musterstraße 1", loaded.street)
assertEquals("12345", loaded.postalCode)
assertEquals("Musterstadt", loaded.city)
assertEquals("0123 456789", loaded.phone1)
assertEquals("0987 654321", loaded.phone2)
assertNull(loaded.phone3)
// Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit.
assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_aktualisiert_bestehenden_patienten_und_behaelt_createdAt() = runDbTest {
val first = repo.upsert(patient())
// createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper).
val second = repo.upsert(patient(lastName = "Musterfrau"))
assertEquals(first.createdAt.toEpochMilli(), second.createdAt.toEpochMilli())
assertTrue(second.updatedAt.toEpochMilli() >= first.updatedAt.toEpochMilli())
val loaded = repo.getById("p-1")!!
assertEquals("Musterfrau", loaded.lastName)
assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_erzeugt_bei_unterschiedlichen_ids_getrennte_datensaetze() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
assertEquals("Max", repo.getById("p-1")!!.firstName)
assertEquals("Erika", repo.getById("p-2")!!.firstName)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getById("unbekannt"))
}
@Test
fun newId_liefert_gueltige_und_eindeutige_uuids() {
val a = repo.newId()
val b = repo.newId()
assertEquals(a, UUID.fromString(a).toString())
assertTrue(a != b)
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_nur_bei_gleichem_namen_und_geburtsdatum() = runDbTest {
repo.upsert(patient())
assertEquals("p-1", repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 14))?.id)
assertNull(repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 15)))
assertNull(repo.findExisting("Moritz", "Mustermann", LocalDate.of(1980, 3, 14)))
assertNull(repo.findExisting("Max", "Musterfrau", LocalDate.of(1980, 3, 14)))
}
// --- Dokumentationen / Löschen -------------------------------------------------------
@Test
fun hasDocumentations_ist_erst_nach_dem_anlegen_einer_dokumentation_true() = runDbTest {
repo.upsert(patient())
assertFalse(repo.hasDocumentations("p-1"))
env.documentationRepository.createEmpty("p-1")
assertTrue(repo.hasDocumentations("p-1"))
}
@Test
fun delete_entfernt_patienten_ohne_dokumentation() = runDbTest {
repo.upsert(patient())
repo.delete("p-1")
assertNull(repo.getById("p-1"))
}
@Test
fun delete_wird_bei_vorhandener_dokumentation_von_der_datenbank_verhindert() = runDbTest {
repo.upsert(patient())
val doc = env.documentationRepository.createEmpty("p-1")
expectThrows<SQLiteConstraintException> { repo.delete("p-1") }
assertNotNull(repo.getById("p-1"))
assertNotNull(env.documentationRepository.getFullDocumentation(doc.id))
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.upsert(patient())
repo.delete("unbekannt")
assertNotNull(repo.getById("p-1"))
}
// --- Liste / Suche -------------------------------------------------------------------
@Test
fun observeAll_sortiert_nach_nachname_dann_vorname() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Müller"))
repo.upsert(patient(id = "p-2", firstName = "Anna", lastName = "Müller"))
repo.upsert(patient(id = "p-3", firstName = "Zoe", lastName = "Adler"))
val list = repo.observeAll().first()
assertEquals(listOf("p-3", "p-2", "p-1"), list.map { it.id })
assertEquals("Zoe Adler", list.first().fullName)
}
@Test
fun observeAll_zeigt_datum_der_letzten_dokumentation_oder_null() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
val older = env.documentationRepository.createEmpty("p-1")
val newer = env.documentationRepository.createEmpty("p-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val byId = repo.observeAll().first().associateBy { it.id }
assertEquals(Instant.ofEpochMilli(2_000L), byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun search_findet_ueber_nachname_vorname_und_geburtsdatum() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Mustermann"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau", birthDate = LocalDate.of(1975, 12, 1)))
// Beide Nachnamen beginnen mit "Muster": sortiert nach Nachname -> Musterfrau vor Mustermann.
assertEquals(listOf("p-2", "p-1"), repo.search("Muster").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("Max").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("Erika").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("1975-12").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
@Test
fun search_ist_unabhaengig_von_gross_und_kleinschreibung() = runDbTest {
repo.upsert(patient())
assertEquals(listOf("p-1"), repo.search("mustermann").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("MAX").first().map { it.id })
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.data.repository
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/** Repository-Tests für das Wundversorger-Register ([WoundCarerRepository]). */
@RunWith(AndroidJUnit4::class)
class WoundCarerRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.woundCarerRepository
private val storage get() = env.woundCarerSignatureStorage
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** updatedAt basiert auf System.currentTimeMillis() - kurze Pause für eindeutige Reihenfolge. */
private fun tick() = Thread.sleep(5)
@Test
fun getMostRecentlyUsed_ist_null_solange_nie_eine_signatur_gespeichert_wurde() = runDbTest {
assertNull(repo.getMostRecentlyUsed())
}
@Test
fun recordSignatureUsage_legt_neuen_eintrag_mit_eigener_signaturdatei_an() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val carer = repo.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt", carer.name)
assertEquals(1, storage.saved.size)
assertEquals(carer.id to carer.signatureFileName, storage.saved.single())
}
@Test
fun recordSignatureUsage_aktualisiert_bekannten_namen_case_insensitiv_und_ersetzt_die_signatur() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val first = repo.getMostRecentlyUsed()!!
tick()
repo.recordSignatureUsage("anna schmidt", signatureBitmap())
val second = repo.getMostRecentlyUsed()!!
assertEquals(first.id, second.id)
assertEquals("anna schmidt", second.name) // zuletzt getippte Schreibweise
assertNotEquals(first.signatureFileName, second.signatureFileName)
assertTrue(second.updatedAt >= first.updatedAt)
assertEquals(1, repo.search("a").size)
// Alte Signaturdatei wurde entfernt, genau eine Datei bleibt zugeordnet.
assertEquals(listOf(first.id to first.signatureFileName), storage.deleted)
}
@Test
fun recordSignatureUsage_ignoriert_fehler_beim_loeschen_der_alten_signaturdatei() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
storage.failOnDelete = true
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(1, repo.search("Anna").size)
}
@Test
fun getMostRecentlyUsed_liefert_den_zuletzt_aktualisierten_wundversorger() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
assertEquals("Bernd Beispiel", repo.getMostRecentlyUsed()?.name)
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", repo.getMostRecentlyUsed()?.name)
}
// --- search --------------------------------------------------------------------------
@Test
fun search_liefert_praefix_treffer_case_insensitiv_neueste_zuerst() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Andreas Müller", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
val result = repo.search("an")
assertEquals(listOf("Andreas Müller", "Anna Schmidt"), result.map { it.name })
}
@Test
fun search_trimmt_die_eingabe_und_liefert_bei_leerer_eingabe_nichts() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(listOf("Anna Schmidt"), repo.search(" Ann ").map { it.name })
assertTrue(repo.search("").isEmpty())
assertTrue(repo.search(" ").isEmpty())
}
@Test
fun search_ist_ein_praefix_und_kein_teilstring_abgleich() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertTrue(repo.search("Schmidt").isEmpty())
}
@Test
fun search_beachtet_das_limit() = runDbTest {
for (name in listOf("Anna A", "Anna B", "Anna C", "Anna D")) {
repo.recordSignatureUsage(name, signatureBitmap())
tick()
}
assertEquals(2, repo.search("Anna", limit = 2).size)
assertEquals(4, repo.search("Anna").size) // Standardlimit 5 deckt alle vier ab
assertEquals(listOf("Anna D", "Anna C"), repo.search("Anna", limit = 2).map { it.name })
}
// --- loadSignatureBitmap -------------------------------------------------------------
@Test
fun loadSignatureBitmap_delegiert_an_den_wundversorger_speicher() = runDbTest {
val bitmap = signatureBitmap()
storage.decodeResult = bitmap
val result = repo.loadSignatureBitmap("carer-1", "sig.png")
assertEquals(bitmap, result)
assertEquals(listOf("carer-1" to "sig.png"), storage.decodeCalls)
}
@Test
fun loadSignatureBitmap_liefert_null_wenn_die_datei_fehlt() = runDbTest {
storage.decodeResult = null
assertNull(repo.loadSignatureBitmap("carer-1", "fehlt.png"))
}
}
@@ -0,0 +1,154 @@
package de.medapp.wunddoku.testutil
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
/** Builder für Room-Entities mit sinnvollen Standardwerten - Tests überschreiben nur, was sie brauchen. */
fun patientEntity(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
phone1: String = "0123 456789",
phone2: String? = null,
phone3: String? = null,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
) = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun documentationEntity(
id: String = "d-1",
patientId: String = "p-1",
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
continuedFromDocumentationId: String? = null,
pdfLastGeneratedAt: Long? = null,
catalogVersion: Int = 4,
firstNameSnapshot: String = "Max",
lastNameSnapshot: String = "Mustermann",
birthDateSnapshot: String = "1980-03-14",
streetSnapshot: String = "Musterstraße 1",
postalCodeSnapshot: String = "12345",
citySnapshot: String = "Musterstadt",
) = DocumentationEntity(
id = id,
patientId = patientId,
status = status,
createdAt = createdAt,
updatedAt = updatedAt,
continuedFromDocumentationId = continuedFromDocumentationId,
pdfLastGeneratedAt = pdfLastGeneratedAt,
catalogVersion = catalogVersion,
patientFirstNameSnapshot = firstNameSnapshot,
patientLastNameSnapshot = lastNameSnapshot,
patientBirthDateSnapshot = birthDateSnapshot,
patientStreetSnapshot = streetSnapshot,
patientPostalCodeSnapshot = postalCodeSnapshot,
patientCitySnapshot = citySnapshot,
)
fun blockStateEntity(
id: String = "b-1",
documentationId: String = "d-1",
blockDefinitionId: String = "wundart",
title: String = "Wundart",
order: Int = 1,
updatedAt: Long = 1_000L,
) = BlockStateEntity(
id = id,
documentationId = documentationId,
blockDefinitionId = blockDefinitionId,
titleSnapshot = title,
blockOrder = order,
updatedAt = updatedAt,
)
fun checkItemStateEntity(
id: String = "c-1",
blockStateId: String = "b-1",
definitionId: String = "item_1",
label: String = "Eintrag 1",
order: Int = 1,
checked: Boolean = false,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItemStateEntity(
id = id,
blockStateId = blockStateId,
checkItemDefinitionId = definitionId,
labelSnapshot = label,
itemOrder = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
fun photoEntity(
id: String = "ph-1",
documentationId: String = "d-1",
fileName: String = "foto.jpg",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = PhotoEntity(
id = id,
documentationId = documentationId,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun signatureEntity(
id: String = "s-1",
documentationId: String = "d-1",
signerName: String = "Anna Schmidt",
fileName: String = "unterschrift.png",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = SignatureEntity(
id = id,
documentationId = documentationId,
signerName = signerName,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun woundCarerEntity(
id: String = "w-1",
name: String = "Anna Schmidt",
signatureFileName: String = "w-sig.png",
updatedAt: Long = 1_000L,
) = WoundCarerEntity(
id = id,
name = name,
signatureFileName = signatureFileName,
updatedAt = updatedAt,
)
@@ -0,0 +1,138 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfExportModel
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureStorage
import java.io.File
import java.io.IOException
/** Aufgezeichneter Aufruf von [PhotoStorage.duplicate]. */
data class DuplicateCall(
val sourceDocumentationId: String,
val sourceFileName: String,
val targetDocumentationId: String,
)
/**
* In-Memory-Fake für [PhotoStorage]: schreibt keine Dateien, zeichnet aber alle Aufrufe auf und
* vergibt eindeutige Dateinamen. Fehler lassen sich gezielt auslösen.
*/
class FakePhotoStorage : PhotoStorage {
val imported = mutableListOf<Pair<String, Uri>>()
val duplicated = mutableListOf<DuplicateCall>()
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
var failOnDelete = false
var failOnDeleteAll = false
/** 1-basierte Nummer des [duplicate]-Aufrufs, der mit einer [IOException] fehlschlagen soll. */
var failOnDuplicateCall: Int? = null
private var counter = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
imported += documentationId to sourceUri
return "imported_${++counter}.jpg"
}
override suspend fun duplicate(
sourceDocumentationId: String,
sourceFileName: String,
targetDocumentationId: String,
): String {
duplicated += DuplicateCall(sourceDocumentationId, sourceFileName, targetDocumentationId)
if (failOnDuplicateCall == duplicated.size) throw IOException("Kopieren fehlgeschlagen (Test)")
return "copy_${++counter}.jpg"
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/photos/$documentationId/$fileName")
override suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? = null
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = null
}
/** In-Memory-Fake für [SignatureStorage] (analog [FakePhotoStorage]). */
class FakeSignatureStorage : SignatureStorage {
val saved = mutableListOf<Pair<String, String>>() // key (documentationId bzw. woundCarerId) -> fileName
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
val decodeCalls = mutableListOf<Pair<String, String>>()
var failOnDelete = false
var failOnDeleteAll = false
var decodeResult: Bitmap? = null
private var counter = 0
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
val fileName = "sig_${++counter}.png"
saved += documentationId to fileName
return fileName
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/signatures/$documentationId/$fileName")
override suspend fun decode(documentationId: String, fileName: String): Bitmap? {
decodeCalls += documentationId to fileName
return decodeResult
}
}
/** Zeichnet die an den Exporter übergebenen Modelle auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfExporter : DocumentationPdfExporter {
val models = mutableListOf<PdfExportModel>()
var result: ((PdfExportModel) -> PdfExportResult) = { model ->
PdfExportResult.Success(File("/fake/pdfs/${model.fileName}"))
}
override suspend fun export(model: PdfExportModel): PdfExportResult {
models += model
return result(model)
}
}
/** Zeichnet Aufrufe auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfToImagesConverter : PdfToImagesConverter {
val calls = mutableListOf<Pair<String, File>>()
var result: PdfToImagesResult = PdfToImagesResult.Success(emptyList())
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult {
calls += documentationId to pdfFile
return result
}
}
@@ -0,0 +1,107 @@
package de.medapp.wunddoku.testutil
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.dao.BlockStateDao
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
/**
* Basisklasse für alle Room-Tests (QA-002): echte In-Memory-Datenbank mit demselben Schema,
* denselben Foreign Keys und denselben Indizes wie in Produktion. Jeder Test bekommt eine
* frische, leere Datenbank.
*/
@RunWith(AndroidJUnit4::class)
abstract class RoomTestBase {
protected lateinit var db: AppDatabase
protected val patientDao: PatientDao get() = db.patientDao()
protected val documentationDao: DocumentationDao get() = db.documentationDao()
protected val blockStateDao: BlockStateDao get() = db.blockStateDao()
protected val checkItemStateDao: CheckItemStateDao get() = db.checkItemStateDao()
protected val photoDao: PhotoDao get() = db.photoDao()
protected val signatureDao: SignatureDao get() = db.signatureDao()
protected val woundCarerDao: WoundCarerDao get() = db.woundCarerDao()
@Before
fun createDatabase() {
db = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
}
@After
fun closeDatabase() {
db.close()
}
// --- Seed-Helfer ---------------------------------------------------------------------
protected suspend fun seedPatient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
): PatientEntity {
val entity = patientEntity(id = id, firstName = firstName, lastName = lastName, birthDate = birthDate)
patientDao.insert(entity)
return entity
}
/** Legt eine Dokumentation an; fehlt der Patient, wird er mit Standarddaten angelegt. */
protected suspend fun seedDocumentation(
id: String = "d-1",
patientId: String = "p-1",
createdAt: Long = 1_000L,
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
): DocumentationEntity {
if (patientDao.getById(patientId) == null) seedPatient(id = patientId)
val entity = documentationEntity(id = id, patientId = patientId, createdAt = createdAt, status = status)
documentationDao.insert(entity)
return entity
}
// --- Roh-SQL-Helfer (unabhängig von DAO-Logik prüfen, was wirklich in der DB steht) ----
protected fun queryInt(sql: String): Int =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
}
protected fun queryString(sql: String): String =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
protected fun countRows(table: String): Int = queryInt("SELECT COUNT(*) FROM $table")
protected fun execSql(sql: String) {
db.openHelper.writableDatabase.execSQL(sql)
}
}
/**
* Wartet (mit Timeout), bis ein Flow einen Wert liefert, der [predicate] erfüllt.
* Robust für Reaktivitäts-Tests, egal ob Änderung vor oder nach dem ersten Emit passiert.
*/
suspend fun <T> Flow<T>.awaitMatching(timeoutMs: Long = 5_000L, predicate: (T) -> Boolean): T =
withTimeout(timeoutMs) { first { predicate(it) } }
@@ -0,0 +1,133 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
/**
* Gemeinsame Testumgebung für alle Instrumented-Tests: echte Room-Datenbank im Arbeitsspeicher
* (gleiche Entities/DAOs/Foreign Keys wie in Produktion), aber Fakes für Dateispeicher und
* PDF-Erzeugung. Jeder Test erzeugt eine eigene Instanz und ruft am Ende [close] auf.
*/
class TestEnvironment {
val db: AppDatabase = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
val photoStorage = FakePhotoStorage()
val signatureStorage = FakeSignatureStorage()
val woundCarerSignatureStorage = FakeSignatureStorage()
val pdfExporter = FakePdfExporter()
val pdfToImagesConverter = FakePdfToImagesConverter()
val patientRepository = PatientRepository(patientDao = db.patientDao())
val woundCarerRepository = WoundCarerRepository(
woundCarerDao = db.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
val documentationRepository = DocumentationRepository(
documentationDao = db.documentationDao(),
blockStateDao = db.blockStateDao(),
checkItemStateDao = db.checkItemStateDao(),
photoDao = db.photoDao(),
signatureDao = db.signatureDao(),
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
)
fun close() = db.close()
/** Legt direkt über das DAO einen Patienten an (ohne Repository-Logik). */
suspend fun insertPatient(
id: String = "patient-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
): PatientEntity {
val entity = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = "0123 456789",
phone2 = null,
phone3 = null,
createdAt = 1_000L,
updatedAt = 1_000L,
)
db.patientDao().insert(entity)
return entity
}
/** Setzt createdAt einer Dokumentation gezielt (vermeidet zeitabhängige Tests). */
suspend fun setCreatedAt(documentationId: String, createdAt: Long) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(createdAt = createdAt))
}
suspend fun setStatus(documentationId: String, status: DocumentationStatus) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(status = status))
}
}
/** Führt einen Test mit Suspend-Aufrufen aus; Rückgabetyp Unit, damit JUnit die Methode akzeptiert. */
fun runDbTest(block: suspend CoroutineScope.() -> Unit) {
runBlocking { block() }
}
/** Erstes Item eines Blocks, das eine normale Checkbox ist (kein Freitext, hint oder strukturierte Eingabe). */
fun DocumentationBlock.firstPlainItem(): CheckItem =
items.first { !it.isFreeText && !it.isStaticHint && it.inputSpec.isEmpty() }
/** Weißes Bitmap ohne jeden Strich - gilt als "leere Signatur". */
fun blankBitmap(): Bitmap =
Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }
/** Weißes Bitmap mit einem schwarzen Strich - gilt als gültige Signatur. */
fun signatureBitmap(): Bitmap = blankBitmap().also { bitmap ->
val paint = Paint().apply {
color = Color.BLACK
strokeWidth = 3f
}
Canvas(bitmap).drawLine(2f, 2f, 38f, 18f, paint)
}
/** Erwartet, dass [block] eine Exception vom Typ [T] wirft, und liefert sie zurück. */
inline fun <reified T : Throwable> expectThrows(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
@@ -18,6 +18,13 @@ interface SignatureDao {
@Query("SELECT * FROM signatures WHERE id = :id")
suspend fun getById(id: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId")
/** Bereits stabil nach [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex] sortiert
* - dieselbe Reihenfolge, in der Signaturen später auch im PDF erscheinen (PDF-003). */
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId ORDER BY orderIndex")
suspend fun getForDocumentation(documentationId: String): List<SignatureEntity>
/** Für die Vergabe des nächsten [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex]
* beim Anlegen einer neuen Signatur - exakt analog [PhotoDao.getMaxOrderIndex]. */
@Query("SELECT COALESCE(MAX(orderIndex), -1) FROM signatures WHERE documentationId = :documentationId")
suspend fun getMaxOrderIndex(documentationId: String): Int
}
@@ -0,0 +1,33 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import de.medapp.wunddoku.data.entity.WoundCarerEntity
@Dao
interface WoundCarerDao {
/** Ersetzt einen vorhandenen Eintrag mit derselben [WoundCarerEntity.id] vollständig (Upsert
* per Primärschlüssel) - siehe [de.medapp.wunddoku.data.repository.WoundCarerRepository]
* (Find-by-Name + Wiederverwendung der gefundenen ID) für die eigentliche
* "Neuanlegen-oder-Aktualisieren"-Logik anhand des Namens. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(entity: WoundCarerEntity)
/** Case-insensitiver Exakt-Abgleich - entscheidet, ob unter diesem (getrimmten) Namen
* bereits ein Wundversorger bekannt ist (dann: Signatur aktualisieren) oder ein neuer Eintrag
* angelegt werden muss. */
@Query("SELECT * FROM wound_carers WHERE name = :name COLLATE NOCASE LIMIT 1")
suspend fun findByName(name: String): WoundCarerEntity?
/** Namensvorschläge nach den ersten eingegebenen Buchstaben (Präfix, case-insensitiv),
* neueste Verwendung zuerst. */
@Query("SELECT * FROM wound_carers WHERE name LIKE :prefix || '%' COLLATE NOCASE ORDER BY updatedAt DESC LIMIT :limit")
suspend fun searchByNamePrefix(prefix: String, limit: Int): List<WoundCarerEntity>
/** Für die Vorbelegung des Signatur-Dialogs mit dem zuletzt verwendeten Wundversorger. */
@Query("SELECT * FROM wound_carers ORDER BY updatedAt DESC LIMIT 1")
suspend fun getMostRecentlyUsed(): WoundCarerEntity?
}
@@ -11,12 +11,14 @@ import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
/**
@@ -26,18 +28,31 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
* Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung -
* Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von
* CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf
* DocumentationEntity.
* DocumentationEntity. Dieser Sprung nutzte noch `fallbackToDestructiveMigration()` (die App
* befand sich vor dem produktiven Einsatz, keine echten Patientendaten zu migrieren).
*
* `fallbackToDestructiveMigration()`: Da die App sich noch vor dem produktiven Einsatz befindet
* und keine echten Patientendaten migriert werden müssen, wird hier bewusst auf eine
* geschriebene `Migration`-Klasse verzichtet - die lokale Datenbank wird bei einem
* Versionssprung einfach neu angelegt (bestehende lokale Testdaten auf dem Gerät/Emulator gehen
* dabei verloren). Das ist eine bewusste, vorübergehende Entwicklungsentscheidung: VOR dem
* ersten produktiven Rollout muss dies durch eine echte `Migration(1, 2) { ... }` ersetzt
* werden, die reale Patientendaten erhält (insbesondere: die bisherige `address`-Spalte müsste
* dann sinnvoll in street/postalCode/city überführt werden, z. B. gesamten Altwert nach
* `street` und postalCode/city leer lassen, da eine automatische Text-Zerlegung einer
* Freitext-Adresse nicht zuverlässig möglich ist).
* Version 3 (vorher 2, MEDIA-004/PDF-003): `signatures.orderIndex` neu (stabile Reihenfolge
* mehrerer Signaturen je Dokumentation) sowie `signatures.signerName` von NULLABLE auf NOT NULL.
* Anders als beim Sprung 1->2 wird HIER eine echte, verlustfreie [Migration] verwendet
* ([MIGRATION_2_3]) - reale Installationen können ab jetzt bereits Patienten-/
* Dokumentationsdaten enthalten, die nicht stillschweigend durch einen destruktiven Fallback
* verloren gehen dürfen (Aufgabenstellung Abschnitt 23). Der bisherige
* `fallbackToDestructiveMigration()`-Aufruf wurde deshalb entfernt; fehlt für einen künftigen
* Versionssprung eine passende Migration, bricht Room den Aufbau der Datenbank kontrolliert mit
* einer Exception ab, statt Daten unbemerkt zu löschen.
*
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
* 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.
*
* Version 6 (vorher 5, Änderungswunsch): `check_item_states.inputSpec` neu (strukturierte
* Eingabefelder wie Zahl/Auswahlliste statt Freitext, s. [de.medapp.wunddoku.domain.model.InputSpec])
* - ebenfalls rein additive Spalte mit Default-Wert ([MIGRATION_5_6]), keine bestehenden Daten
* verändert.
*/
@Database(
entities = [
@@ -47,8 +62,9 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
CheckItemStateEntity::class,
PhotoEntity::class,
SignatureEntity::class,
WoundCarerEntity::class,
],
version = 2,
version = 6,
exportSchema = true,
)
@TypeConverters(DocumentationStatusConverter::class)
@@ -60,6 +76,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun checkItemStateDao(): CheckItemStateDao
abstract fun photoDao(): PhotoDao
abstract fun signatureDao(): SignatureDao
abstract fun woundCarerDao(): WoundCarerDao
companion object {
private const val DATABASE_NAME = "wunddoku.db"
@@ -74,7 +91,8 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
DATABASE_NAME,
)
.fallbackToDestructiveMigration() // s. Klassenkommentar: vorübergehend, vor Produktivbetrieb ersetzen
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.build().also { instance = it }
}
}
@@ -0,0 +1,150 @@
package de.medapp.wunddoku.data.database
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Migration von Schema-Version 2 auf 3 (MEDIA-004/PDF-003: Mehrere Signaturen pro
* Dokumentation).
*
* Zwei Änderungen am `signatures`-Schema, die NICHT über ein einfaches `ALTER TABLE ... ADD
* COLUMN` abgebildet werden können:
*
* 1. Neue Spalte `orderIndex INTEGER NOT NULL` für eine stabile Reihenfolge mehrerer
* Signaturen (bisher gab es nur eine implizite, undefinierte Reihenfolge).
* 2. `signerName` wechselt von NULLABLE auf NOT NULL. SQLite kann eine bestehende Spalte nicht
* nachträglich auf NOT NULL umstellen - deshalb wird die Tabelle komplett neu aufgebaut
* (neue Tabelle mit Zielschema anlegen, Daten verlustfrei übernehmen, alte Tabelle
* entfernen, neue Tabelle umbenennen, Index wiederherstellen).
*
* Es wird NICHT angenommen, dass die Tabelle leer ist, nur weil die Signaturfunktion vor
* dieser Version über keine UI erreichbar war (Aufgabenstellung Abschnitt 22) - die Migration
* behandelt den allgemeinen Fall mit ggf. bereits vorhandenen Zeilen:
*
* - `orderIndex` wird pro `documentationId` anhand von `createdAt` (mit `id` als
* Tie-Breaker für den unwahrscheinlichen Fall exakt gleicher Zeitstempel) rückwirkend
* vergeben, beginnend bei 0 - über eine korrelierte Unterabfrage, da SQLite in der von Room
* unterstützten Form kein `ROW_NUMBER()`-Fenster in einem `UPDATE` bereitstellt.
* - `signerName` wird für Altzeilen mit NULL **oder** leerem/nur-Leerzeichen-Wert auf den klar
* als historisch fehlend erkennbaren technischen Platzhalter `"(nicht erfasst)"` gesetzt
* (ausdrücklich freigegeben - NICHT `""`, NICHT `"Unbekannt"`, da Letzteres wie ein echter
* Name wirken könnte). Der leere-String-Fall wurde defensiv ergänzt: `signerName` war in
* Schema 2 zwar nullable, aber nie auf "nicht blank" geprüft - ein rein theoretisch über
* einen früheren Code-Pfad denkbarer Leerstring soll denselben Platzhalter erhalten wie
* NULL, damit hier keine Zeile mit einem leeren, aber technisch "vorhandenen" Namen entsteht.
* Diese Regel gilt ausschließlich für migrierte Altbestände - neu erfasste Signaturen
* erzwingen weiterhin serverseitig (Repository) ein nicht-leeres, getrimmtes Pflichtfeld.
*
* Da die Signaturfunktion vor dieser Version über keine UI erreichbar war, ist in der Praxis auf
* so gut wie jeder realen Installation ohnehin mit einer leeren `signatures`-Tabelle zu rechnen
* - die obige Logik greift dann einfach nicht (INSERT ... SELECT liefert 0 Zeilen).
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `signatures_new` (
`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
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO `signatures_new` (`id`, `documentationId`, `signerName`, `fileName`, `orderIndex`, `createdAt`)
SELECT
s.id,
s.documentationId,
CASE
WHEN s.signerName IS NULL OR TRIM(s.signerName) = '' THEN '(nicht erfasst)'
ELSE s.signerName
END,
s.fileName,
(
SELECT COUNT(*)
FROM signatures s2
WHERE s2.documentationId = s.documentationId
AND (
s2.createdAt < s.createdAt
OR (s2.createdAt = s.createdAt AND s2.id < s.id)
)
),
s.createdAt
FROM signatures s
""".trimIndent()
)
db.execSQL("DROP TABLE `signatures`")
db.execSQL("ALTER TABLE `signatures_new` RENAME TO `signatures`")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `signatures` (`documentationId`)")
}
}
/**
* Migration von Schema-Version 3 auf 4 (Änderungswunsch: Wundversorger-Register für
* Namensvorschläge und Standard-Vorbelegung im Signatur-Dialog).
*
* Rein additiv: eine neue, komplett eigenständige Tabelle `wound_carers` ohne Fremdschlüssel-
* Bezug zu einer bestehenden Tabelle (unabhängig vom Lebenszyklus einzelner Dokumentationen/
* Signaturen, s. [de.medapp.wunddoku.data.repository.WoundCarerRepository]). Keine bestehenden
* Tabellen/Daten werden hierbei verändert.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `wound_carers` (
`id` TEXT NOT NULL,
`name` TEXT NOT NULL,
`signatureFileName` TEXT NOT NULL,
`updatedAt` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
}
}
/**
* 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"
)
}
}
/**
* Migration von Schema-Version 5 auf 6 (Änderungswunsch: strukturierte Eingabefelder statt
* Freitext, z. B. "Länge/Breite/Tiefe" bei "Größe" oder "Von ... bis ... Uhr" bei "Fistel /
* Tasche" - s. [de.medapp.wunddoku.domain.model.InputSpec]).
*
* Wie schon bei Migration 4->5 rein additiv: eine neue NOT NULL-Spalte mit Default `''` (leerer
* String = "dieser Eintrag hat keine strukturierten Felder", entspricht dem bisherigen
* Verhalten aller bestehenden Zeilen unverändert). Kein Tabellenneubau nötig.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `check_item_states` ADD COLUMN `inputSpec` TEXT NOT NULL DEFAULT ''"
)
}
}
@@ -15,6 +15,27 @@ import androidx.room.PrimaryKey
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
* 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]: 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.
*
* [inputSpec]: NEU. Nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens ein oder
* mehrere strukturierte Eingabefelder besitzt (Zahl oder Auswahlliste, z. B. "Von [3] bis [5]
* Uhr" oder "Länge/Breite/Tiefe"), s. [de.medapp.wunddoku.domain.model.InputSpec] und
* [de.medapp.wunddoku.domain.model.InputSpecCodec]. Die eingegebenen Werte selbst liegen -
* mehrere durch ein Trennzeichen kombiniert - weiterhin in [freeTextValue]; es wird also KEINE
* weitere Spalte pro Feld benötigt. [checked] wird bei einem solchen Eintrag automatisch von der
* UI gesetzt, sobald mindestens ein Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall
* sonst, keine zweite "bearbeitet"-Logik.
*/
@Entity(
tableName = "check_item_states",
@@ -40,4 +61,6 @@ data class CheckItemStateEntity(
val checked: Boolean,
val isFreeText: Boolean = false,
val freeTextValue: String = "",
val isStaticHint: Boolean = false,
val inputSpec: String = "",
)
@@ -7,9 +7,14 @@ import androidx.room.PrimaryKey
/**
* Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an
* DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen
* (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter
* context.filesDir/signatures/ – kein BLOB in SQLite.
* DocumentationEntity, damit eine Dokumentation beliebig viele Unterschriften unterschiedlicher
* Wundversorger besitzen kann (1:n, MEDIA-004). [fileName] verweist auf eine PNG-Datei unter
* context.filesDir/signatures/<documentationId>/ – kein BLOB in SQLite.
*
* Schema-Historie: In Version 2 war [signerName] noch nullable und es gab kein [orderIndex] -
* Migration 2->3 ([de.medapp.wunddoku.data.database.MIGRATION_2_3]) baut die Tabelle deshalb neu
* auf (SQLite kann eine Spalte nicht per ALTER TABLE von NULL auf NOT NULL umstellen) und füllt
* dabei sowohl [orderIndex] als auch fehlende [signerName]-Werte auf.
*/
@Entity(
tableName = "signatures",
@@ -28,7 +33,10 @@ import androidx.room.PrimaryKey
data class SignatureEntity(
@PrimaryKey val id: String,
val documentationId: String,
val signerName: String?,
/** Fachliches Pflichtfeld seit Schema 3 (non-null, getrimmt, nicht blank) - s. Repository. */
val signerName: String,
val fileName: String,
/** Stabile Reihenfolge mehrerer Signaturen einer Dokumentation, analog [de.medapp.wunddoku.data.entity.PhotoEntity.orderIndex]. */
val orderIndex: Int,
val createdAt: Long,
)
@@ -0,0 +1,37 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger-Namen samt ihrer jeweils
* AKTUELLSTEN Signatur - AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: es gibt keine
* Anmeldung, keine Rechte, keine Verknüpfung zu historischen Dokumentationen, und (vorerst) keine
* eigene Verwaltungsansicht zum Bearbeiten/Löschen einzelner Einträge - ausschließlich internes
* Cache für zwei Komfortfunktionen im Signatur-Dialog:
*
* 1. Namensvorschläge nach den ersten eingegebenen Buchstaben.
* 2. Vorbelegung von Name UND Signatur mit dem zuletzt verwendeten Wundversorger beim Öffnen des
* Dialogs (weiterhin änderbar).
*
* WICHTIG: Diese Tabelle hat KEINEN Einfluss auf bereits gespeicherte [SignatureEntity]-Zeilen -
* jede historische Signatur bleibt ein unveränderlicher Snapshot (Name + Bild) zum Zeitpunkt
* ihrer Erstellung. [signatureFileName] hier wird bei jeder neu gezeichneten Signatur unter
* demselben Namen überschrieben ("die letzte geänderte Version wird gespeichert") - komplett
* unabhängig vom Speicherort/Lebenszyklus der pro Dokumentation gespeicherten Signaturen.
*/
@Entity(tableName = "wound_carers")
data class WoundCarerEntity(
@PrimaryKey val id: String,
/** Trimmter Anzeigename, wie zuletzt eingegeben. Abgleich beim Wiedererkennen erfolgt
* case-insensitive (s. WoundCarerDao), gespeichert wird die zuletzt getippte Schreibweise. */
val name: String,
/** Relative Referenz auf die aktuellste PNG-Signatur dieses Wundversorgers, verwaltet über
* eine eigene, von [de.medapp.wunddoku.signature.SignatureStorage]-Instanz mit eigenem
* Wurzelverzeichnis (unabhängig von jeder documentationId). */
val signatureFileName: String,
/** Zeitpunkt der letzten Verwendung/Aktualisierung - bestimmt sowohl die Sortierung der
* Namensvorschläge als auch, wer als Standard beim Öffnen des Dialogs vorbelegt wird
* (jeweils der zuletzt aktualisierte Eintrag). */
val updatedAt: Long,
)
@@ -40,12 +40,15 @@ fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
createdAt = Instant.ofEpochMilli(it.createdAt),
)
},
signatures = signatures.map {
signatures = signatures
.sortedBy { it.orderIndex }
.map {
de.medapp.wunddoku.domain.model.Signature(
id = it.id,
documentationId = it.documentationId,
signerName = it.signerName,
fileName = it.fileName,
orderIndex = it.orderIndex,
createdAt = Instant.ofEpochMilli(it.createdAt),
)
},
@@ -68,6 +71,8 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
order = itemOrder,
checked = checked,
isFreeText = isFreeText,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
freeTextValue = freeTextValue,
)
@@ -21,12 +21,22 @@ import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.DocumentationSummary
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.media.ImageDownsampler
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureFileStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.io.File
import java.time.Instant
import java.util.UUID
@@ -38,6 +48,10 @@ import java.util.UUID
*
* [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein
* frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation).
* [pdfExporter] wird für [exportPdf] benötigt - der Exporter selbst kennt weder Room noch die
* documentationId, nur das fertig gemappte [de.medapp.wunddoku.pdf.PdfExportModel]; das Laden
* der Dokumentation und die Fehlerübersetzung ("Dokumentation existiert nicht") übernimmt
* bewusst das Repository, nicht der Exporter.
*/
class DocumentationRepository(
private val documentationDao: DocumentationDao,
@@ -47,7 +61,10 @@ class DocumentationRepository(
private val signatureDao: SignatureDao,
private val patientDao: PatientDao,
private val photoStorage: PhotoStorage,
private val signatureFileStorage: SignatureFileStorage,
private val signatureStorage: SignatureStorage,
private val woundCarerRepository: WoundCarerRepository,
private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase,
) {
@@ -112,12 +129,14 @@ class DocumentationRepository(
* - status: IN_PROGRESS
* - continuedFromDocumentationId: ID der Quelle
* - Unterschriften: werden NICHT kopiert
* - Fotos: über [copyPhotos] steuerbar, Standard `false` - fachlich noch nicht final
* entschieden
* - Fotos: werden standardmäßig mit übernommen (physisch eigenständige Kopien pro
* Dokumentation, s. [PhotoStorage.duplicate]) - fachliche Entscheidung, ursprünglich als
* offen markiert, inzwischen bewusst so festgelegt. Über [copyPhotos] weiterhin
* abschaltbar, falls dafür später doch ein Anwendungsfall entsteht.
*/
suspend fun continueDocumentation(
sourceDocumentationId: String,
copyPhotos: Boolean = false,
copyPhotos: Boolean = true,
): Documentation = db.withTransaction {
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
@@ -171,7 +190,9 @@ class DocumentationRepository(
itemOrder = item.itemOrder,
checked = item.checked,
isFreeText = item.isFreeText,
isStaticHint = item.isStaticHint,
freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
)
}
)
@@ -181,7 +202,11 @@ class DocumentationRepository(
if (copyPhotos) {
for (sourcePhoto in source.photos) {
val copiedFileName = photoStorage.duplicate(sourcePhoto.fileName)
val copiedFileName = photoStorage.duplicate(
sourceDocumentationId = source.documentation.id,
sourceFileName = sourcePhoto.fileName,
targetDocumentationId = newDocumentationId,
)
photoDao.insert(
PhotoEntity(
id = UUID.randomUUID().toString(),
@@ -224,7 +249,7 @@ class DocumentationRepository(
}
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
val fileName = photoStorage.copyFrom(sourceUri)
val fileName = photoStorage.importFrom(documentationId, sourceUri)
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now()
val entity = PhotoEntity(
@@ -247,44 +272,155 @@ class DocumentationRepository(
suspend fun removePhoto(photoId: String) {
val photo = photoDao.getById(photoId) ?: return
photoDao.delete(photo)
runCatching { photoStorage.delete(photo.fileName) }
runCatching { photoStorage.delete(photo.documentationId, photo.fileName) }
}
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature {
val fileName = signatureFileStorage.save(bitmap)
/**
* Erzeugt aus einer Wunddokumentation ein PDF im internen App-Speicher (PDF-001/002). Lädt
* die vollständige Dokumentation, übersetzt "nicht gefunden" in einen fachlichen Fehler
* statt einer NullPointerException, mappt anschließend auf das schlanke [de.medapp.wunddoku.pdf.PdfExportModel]
* (dabei werden über [DocumentationToPdfExportModelMapper] ausschließlich bearbeitete
* Blöcke gemäß der bestehenden BlockEditedPolicy sowie die historischen Snapshot-Werte
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
*/
suspend fun exportPdf(documentationId: String): PdfExportResult {
val documentation = getFullDocumentation(documentationId)
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
val model = DocumentationToPdfExportModelMapper.map(documentation)
return pdfExporter.export(model)
}
/**
* Rendert eine bereits erzeugte PDF-Datei (s. [exportPdf]) seitenweise in JPEG-Dateien
* (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können"). Nimmt bewusst die
* fertige [pdfFile] entgegen, statt die Dokumentation erneut zu laden/zu mappen - der
* Aufrufer hat sie direkt aus einem vorangegangenen [exportPdf]-Aufruf zur Verfügung, ein
* erneutes Laden wäre unnötige Doppelarbeit.
*/
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
pdfToImagesConverter.convert(documentationId, pdfFile)
/**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
* Compose ausschließlich über das ViewModel aufgerufen, nie direkt - siehe Architektur:
* Compose führt keine Dateizugriffe selbst durch.
*/
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
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
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
* genug für die direkte Anzeige. Liefert `null` bei fehlender/beschädigter Datei (kein
* Absturz, konsistent zu [loadPhotoThumbnail]).
*/
suspend fun loadSignatureBitmap(documentationId: String, fileName: String): Bitmap? =
signatureStorage.decode(documentationId, fileName)
/**
* Speichert eine neue Signatur (MEDIA-003/004). [signerName] ist fachliches Pflichtfeld:
* wird getrimmt, ein danach leerer String wird abgelehnt (Aufgabenstellung Abschnitt 8) -
* ebenso eine leere Zeichnung ([SignatureBitmapValidation.isBlank], Abschnitt 12: zweite,
* von der UI unabhängige Absicherungsebene). [orderIndex] wird analog [addPhoto] fortlaufend
* je Dokumentation vergeben, damit mehrere Signaturen eine stabile Reihenfolge haben
* (Anzeige UND spätere PDF-Reihenfolge, PDF-003). Die Namensvalidierung erfolgt VOR dem
* Schreiben der PNG-Datei, damit bei einem ungültigen Namen keine verwaiste Datei entsteht.
*
* Aktualisiert nach erfolgreichem Speichern zusätzlich das Wundversorger-Register
* ([WoundCarerRepository.recordSignatureUsage]) mit derselben Signatur - unabhängig davon,
* ob [bitmap] frisch gezeichnet oder aus einem Namensvorschlag im Dialog übernommen wurde.
* Das hat KEINEN Einfluss auf den hier gerade erzeugten, unveränderlichen [Signature]-
* Snapshot dieser Dokumentation.
*/
suspend fun saveSignature(documentationId: String, signerName: String, bitmap: Bitmap): Signature {
val trimmedName = signerName.trim()
require(trimmedName.isNotEmpty()) { "Der Name des Wundversorgers darf nicht leer sein." }
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
val fileName = signatureStorage.save(documentationId, bitmap)
val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now()
val entity = SignatureEntity(
id = UUID.randomUUID().toString(),
documentationId = documentationId,
signerName = signerName,
signerName = trimmedName,
fileName = fileName,
orderIndex = nextOrderIndex,
createdAt = now.toEpochMilli(),
)
signatureDao.insert(entity)
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now)
woundCarerRepository.recordSignatureUsage(trimmedName, bitmap)
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now)
}
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. Signaturen sind nach dem
* Speichern unveränderlich (Aufgabenstellung Abschnitt 17) - "korrigieren" bedeutet fachlich
* immer löschen + neu erfassen, nie ein nachträgliches Bearbeiten. */
suspend fun removeSignature(signatureId: String) {
val signature = signatureDao.getById(signatureId) ?: return
signatureDao.delete(signature)
runCatching { signatureFileStorage.delete(signature.fileName) }
runCatching { signatureStorage.delete(signature.documentationId, signature.fileName) }
}
suspend fun delete(documentationId: String) {
val entity = documentationDao.getById(documentationId) ?: return
val photos = photoDao.getForDocumentation(documentationId)
val signatures = signatureDao.getForDocumentation(documentationId)
documentationDao.delete(entity)
photos.forEach { runCatching { photoStorage.delete(it.fileName) } }
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } }
// Je ein einziger Verzeichnis-Löschvorgang statt Einzeldateien für Fotos UND Signaturen
// - beide Storage-Implementierungen organisieren ihre Dateien pro documentationId in
// einem eigenen Unterordner (s. PhotoStorage-/SignatureStorage-Kommentar). Room-Cascade
// entfernt zwar die PhotoEntity-/SignatureEntity-Zeilen automatisch, aber keine Dateien
// aus filesDir - das übernimmt ausschließlich diese Methode. Da deleteAll() über den
// Ordnernamen (documentationId) statt über zuvor geladene DB-Zeilen arbeitet, muss hier
// - anders als vorher bei den Signaturen - auch keine Liste mehr vor dem DB-Delete
// geladen werden.
runCatching { photoStorage.deleteAll(documentationId) }
runCatching { signatureStorage.deleteAll(documentationId) }
}
/**
* 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
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL
* und BlockDefinition.freeTextDefinitionId.
* Katalog-Eintrag PLUS eine zusätzliche, automatisch angehängte generische Freitext-Option
* (isFreeText = true, Label "Freitext") - siehe DocumentationCatalog.FREE_TEXT_LABEL und
* 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`,
* `isStaticHint` und `inputSpec` steuern direkt das Verhalten der jeweiligen Zeile (normale
* Checkbox, eigene benannte Freitext-Checkbox, nicht editierbarer Hinweistext, oder
* strukturierte Eingabefelder) - 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.
*
* Änderungswunsch: Die generische Freitext-Option erscheint jetzt als LETZTES Element im
* Block (itemOrder = höchste vorkommende Katalog-`order` + 1) - zuvor war sie das ERSTE
* Element (itemOrder = 0). Vom Katalog selbst definierte, eigens benannte Freitext-Einträge
* (isFreeText = true) behalten dagegen ihre im Katalog festgelegte Position, sofern der
* jeweilige Block das explizit so vorsieht. 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) {
for (blockDef in DocumentationCatalog.blocks) {
@@ -302,29 +438,39 @@ class DocumentationRepository(
)
)
val normalItems = blockDef.items.map { itemDef ->
val catalogItems = blockDef.items.map { itemDef ->
CheckItemStateEntity(
id = UUID.randomUUID().toString(),
blockStateId = blockStateId,
checkItemDefinitionId = itemDef.id,
labelSnapshot = itemDef.label,
itemOrder = itemDef.order,
checked = false,
isFreeText = false,
// Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
// anderen Einträge (Checkbox, eigene Freitext-Checkbox oder strukturierte
// Eingabe) starten unangehakt/leer, wie bisher.
checked = itemDef.isStaticHint,
isFreeText = itemDef.isFreeText,
isStaticHint = itemDef.isStaticHint,
freeTextValue = "",
inputSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: "",
)
}
val freeTextItem = CheckItemStateEntity(
// Änderungswunsch: generische Freitext-Option jetzt NACH allen Katalog-Einträgen
// dieses Blocks (s. Methodenkommentar) statt davor.
val genericFreeTextOrder = (blockDef.items.maxOfOrNull { it.order } ?: 0) + 1
val genericFreeTextItem = CheckItemStateEntity(
id = UUID.randomUUID().toString(),
blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = blockDef.items.size + 1,
itemOrder = genericFreeTextOrder,
checked = false,
isFreeText = true,
isStaticHint = false,
freeTextValue = "",
inputSpec = "",
)
checkItemStateDao.insertAll(normalItems + freeTextItem)
checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
}
}
}
@@ -0,0 +1,82 @@
package de.medapp.wunddoku.data.repository
import android.graphics.Bitmap
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.signature.SignatureStorage
import java.time.Instant
import java.util.UUID
/**
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger (Namensvorschläge +
* Vorbelegung im Signatur-Dialog mit Name UND Signatur des zuletzt verwendeten Wundversorgers).
*
* AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: keine Anmeldung, keine Rechte, keine
* Verknüpfung zu [DocumentationRepository]-Signaturen, und (vorerst) keine eigene
* Verwaltungsansicht. Ändert insbesondere NIE bereits gespeicherte, historische
* [de.medapp.wunddoku.domain.model.Signature]-Snapshots einzelner Dokumentationen - diese bleiben
* unveränderlich, wie ursprünglich festgelegt. Diese Klasse verwaltet eine komplett separate
* Tabelle (`wound_carers`) und ein separates Dateiverzeichnis
* (`filesDir/wound_carer_signatures/`, s. [de.medapp.wunddoku.signature.InternalSignatureStorage]).
*/
class WoundCarerRepository(
private val woundCarerDao: WoundCarerDao,
private val woundCarerSignatureStorage: SignatureStorage,
) {
/** Für die Vorbelegung des Signatur-Dialogs beim Öffnen: der zuletzt verwendete
* Wundversorger (app-weit, nicht auf eine einzelne Dokumentation beschränkt) - `null`, wenn
* noch nie eine Signatur gespeichert wurde. */
suspend fun getMostRecentlyUsed(): WoundCarer? = woundCarerDao.getMostRecentlyUsed()?.toDomain()
/** Namensvorschläge nach den ersten eingegebenen Buchstaben. Leerer/blank [prefix] liefert
* bewusst keine Vorschläge (kein "alle anzeigen" bei leerem Feld). */
suspend fun search(prefix: String, limit: Int = 5): List<WoundCarer> {
val trimmed = prefix.trim()
if (trimmed.isEmpty()) return emptyList()
return woundCarerDao.searchByNamePrefix(trimmed, limit).map { it.toDomain() }
}
/** Lädt die aktuell hinterlegte Signatur eines bekannten Wundversorgers, z. B. zur Anzeige
* einer Vorschau im Signatur-Dialog nach Auswahl eines Namensvorschlags. */
suspend fun loadSignatureBitmap(woundCarerId: String, fileName: String): Bitmap? =
woundCarerSignatureStorage.decode(woundCarerId, fileName)
/**
* Wird bei JEDER erfolgreich gespeicherten Signatur aufgerufen (s.
* [DocumentationRepository.saveSignature]) und aktualisiert das Register: existiert unter
* [name] (getrimmt, case-insensitiver Abgleich) bereits ein Eintrag, wird dessen Signatur
* durch [bitmap] ersetzt (alte Datei wird gelöscht) und der Zeitstempel aktualisiert -
* "die letzte geänderte Version der Unterschrift eines Wundversorgers wird gespeichert".
* Andernfalls wird ein neuer Eintrag angelegt. Verwendet dieselbe [bitmap]-Instanz, die auch
* als historischer Snapshot der jeweiligen Dokumentation gespeichert wird - es wird lediglich
* eine ZWEITE, unabhängige PNG-Kopie in einem eigenen Verzeichnis abgelegt (kein Teilen
* derselben Datei), damit ein späteres Löschen der Dokumentation dieses Register niemals
* beeinträchtigen kann.
*/
suspend fun recordSignatureUsage(name: String, bitmap: Bitmap) {
val existing = woundCarerDao.findByName(name)
val id = existing?.id ?: UUID.randomUUID().toString()
val newFileName = woundCarerSignatureStorage.save(id, bitmap)
woundCarerDao.insertOrUpdate(
WoundCarerEntity(
id = id,
name = name,
signatureFileName = newFileName,
updatedAt = System.currentTimeMillis(),
)
)
val oldFileName = existing?.signatureFileName
if (oldFileName != null && oldFileName != newFileName) {
runCatching { woundCarerSignatureStorage.delete(id, oldFileName) }
}
}
private fun WoundCarerEntity.toDomain() = WoundCarer(
id = id,
name = name,
signatureFileName = signatureFileName,
updatedAt = Instant.ofEpochMilli(updatedAt),
)
}
@@ -4,10 +4,15 @@ import android.content.Context
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.InternalSignatureFileStorage
import de.medapp.wunddoku.signature.SignatureFileStorage
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage
/**
* Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories,
@@ -26,12 +31,28 @@ class AppContainer(context: Context) {
private val database: AppDatabase by lazy { AppDatabase.getInstance(context) }
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) }
private val signatureStorage: SignatureStorage by lazy { InternalSignatureStorage(context) }
/** Eigenes Wurzelverzeichnis, komplett unabhängig von den dokumentationsbezogenen
* Signaturen - s. [InternalSignatureStorage]-Klassenkommentar. */
private val woundCarerSignatureStorage: SignatureStorage by lazy {
InternalSignatureStorage(context, rootDirName = "wound_carer_signatures")
}
private val pdfExporter: DocumentationPdfExporter by lazy {
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
}
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao())
}
val woundCarerRepository: WoundCarerRepository by lazy {
WoundCarerRepository(
woundCarerDao = database.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
}
val documentationRepository: DocumentationRepository by lazy {
DocumentationRepository(
documentationDao = database.documentationDao(),
@@ -41,7 +62,10 @@ class AppContainer(context: Context) {
signatureDao = database.signatureDao(),
patientDao = database.patientDao(),
photoStorage = photoStorage,
signatureFileStorage = signatureFileStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = database,
)
}
@@ -1,66 +1,602 @@
package de.medapp.wunddoku.domain.catalog
import de.medapp.wunddoku.domain.model.InputFieldDef
import de.medapp.wunddoku.domain.model.InputSpec
/**
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
*
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte").
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden.
* Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
* handschriftlich vorgegebene 22-Punkte-Reihenfolge, zunächst ergänzt/verfeinert nach Abgleich
* mit dem ursprünglichen Papierformular, und in dieser Fassung ein weiteres Mal überarbeitet
* nach konkreten Änderungswünschen (u. a. neue Checkbox-Listen für Wundart/Vitalwerte/
* Auswirkungen aus Fotos, strukturierte Eingabefelder statt Freitext für Maße/Uhrzeiten/Tiefen,
* ein neuer Abschnitt "Wundheilung beeinflussende Faktoren").
*
* WICHTIGE GENERELLE REGEL (Änderungswunsch): Die automatisch an jeden Block angehängte
* generische Freitext-Option (s. [FREE_TEXT_LABEL]) erscheint jetzt als LETZTES Element eines
* Blocks (vorher: als erstes) - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.insertBlocksFromCatalog].
* Vom Katalog selbst eigens benannte Freitext-Einträge (`isFreeText = true` an einem konkreten
* [CheckItemDefinition]) behalten dagegen ihre hier festgelegte Position.
*
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
* Inhalte (Wortlaut-Variante der alten Wundart-Checkboxen, "Schmerzempfinden 1-10") sind
* weiterhin bewusst NICHT enthalten.
*
* 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
* Abschnitt 2.3b.
*
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots).
* Metadatum in Documentation.catalogVersion übernommen. Auf 4 erhöht mit diesem Umbau.
*/
object DocumentationCatalog {
const val CATALOG_VERSION: Int = 1
const val CATALOG_VERSION: Int = 4
/**
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
* "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
* dieselbe Bezeichnung verwenden.
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
* damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe 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"
// Wiederverwendete Auswahllisten für strukturierte Eingabefelder (s. InputFieldDef.Choice).
private val HOUR_OPTIONS: List<String> = (1..12).map { it.toString() }
private val WEEKDAY_COUNT_OPTIONS: List<String> = (1..7).map { it.toString() }
private val MINUTE_1_TO_15_OPTIONS: List<String> = (1..15).map { it.toString() }
private val DEPTH_HALF_CM_OPTIONS: List<String> = listOf(
"0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm",
"4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm",
)
private val DECUBITUS_GRADE_OPTIONS: List<String> = listOf("I", "II", "III", "IV")
val blocks: List<BlockDefinition> = listOf(
BlockDefinition(
id = "block_01",
title = "Block 1",
id = "wundart",
title = "Wundart",
order = 1,
items = listOf(
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1),
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
CheckItemDefinition(
id = "wundart_decubitus",
label = "Decubitus",
order = 3,
inputSpec = InputSpec(
pdfTemplate = "Decubitus Grad {0}",
fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)),
),
),
CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5),
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum_arteriosum", label = "Ulcus Cruris ven.-art.", order = 7),
CheckItemDefinition(id = "wundart_diabetisches_fusssyndrom", label = "Diab. Fuß Syndrom", order = 8),
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung / Dehiszenz", order = 9),
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 10),
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 11),
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 12),
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 13),
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 14),
CheckItemDefinition(
id = "wundart_transplantatverlust",
label = "Wunde nach Transplantatverlust",
order = 15,
inputSpec = InputSpec(
pdfTemplate = "Wunde nach Transplantatverlust, Spalthautdeckung ca. {0}%",
fields = listOf(InputFieldDef.Number(label = "Spalthautdeckung ca. (%)", maxLength = 3)),
),
),
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 16),
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 17),
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 18),
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 19),
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 20),
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 21),
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 22),
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 23),
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 24),
),
),
BlockDefinition(
id = "block_02",
title = "Block 2",
id = "lokalisation",
title = "Lokalisation",
order = 2,
items = listOf(
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1),
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "Multiple Wunden im Bereich", order = 1),
),
),
BlockDefinition(
id = "block_03",
title = "Block 3",
id = "groesse",
title = "Größe",
order = 3,
items = listOf(
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1),
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2),
CheckItemDefinition(id = "groesse_hinweis_masse", label = "Maße", order = 1, isStaticHint = true),
CheckItemDefinition(
id = "groesse_laenge_breite_tiefe",
label = "Maße (Länge / Breite / Tiefe)",
order = 2,
inputSpec = InputSpec(
pdfTemplate = "Länge {0} cm, Breite {1} cm, Tiefe {2} cm",
fields = listOf(
InputFieldDef.Number(label = "Länge (cm)", maxLength = 5, allowDecimal = true),
InputFieldDef.Number(label = "Breite (cm)", maxLength = 5, allowDecimal = true),
InputFieldDef.Number(label = "Tiefe (cm)", maxLength = 5, allowDecimal = true),
),
),
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch.
),
CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true),
// Bewusst OHNE separate Hinweis-Überschrift (anders als bei "Maße" oben): Die
// Checkbox selbst trägt mit "Zu versorgendes Areal" bereits den fachlich
// gewünschten Namen - eine zusätzliche, wortgleiche Überschrift direkt darüber
// würde im PDF als zwei identische, nicht unterscheidbare Zeilen hintereinander
// erscheinen (Hinweistext und Checkbox-Label werden dort gleich dargestellt).
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 5),
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 6, isFreeText = true),
),
),
BlockDefinition(
id = "verlauf",
title = "Verlauf",
order = 4,
items = listOf(
CheckItemDefinition(id = "verlauf_positiv", label = "Positiv", order = 1),
CheckItemDefinition(id = "verlauf_negativ", label = "Negativ", order = 2),
CheckItemDefinition(id = "verlauf_stagnierend", label = "Stagnierend", order = 3),
CheckItemDefinition(id = "verlauf_nicht_bekannt", label = "Nicht bekannt", order = 4),
),
),
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_wie_ausgestanzt", label = "Wie ausgestanzt", order = 1),
CheckItemDefinition(id = "wundgrund_kraterfoermig", label = "Kraterförmig", order = 2),
CheckItemDefinition(id = "wundgrund_unregelmaessig_landkartenartig", label = "Unregelmäßig (landkartenartig/amorph)", order = 3),
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 4),
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 5),
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 6),
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 7),
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 8),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 9),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 10),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 11),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 12),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 13),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 14),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 15),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 16),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 17),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 18),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 19),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 20, 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_biofilm", label = "Biofilm", order = 7),
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 8),
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 9),
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 10),
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 11),
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 12),
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 13),
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 14),
),
),
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_sehr_trockene_haut", label = "Sehr trockene Haut", order = 11),
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 12),
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 13),
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 14),
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 15),
),
),
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,
inputSpec = InputSpec(
pdfTemplate = "Von {0} bis {1} Uhr",
fields = listOf(
InputFieldDef.Choice(label = "Von", options = HOUR_OPTIONS),
InputFieldDef.Choice(label = "Bis", options = HOUR_OPTIONS),
),
),
),
CheckItemDefinition(
id = "fistel_tasche_tiefe",
label = "Tiefe",
order = 5,
inputSpec = InputSpec(
pdfTemplate = "Tiefe: {0}",
fields = listOf(InputFieldDef.Choice(label = "Tiefe", options = DEPTH_HALF_CM_OPTIONS)),
),
),
),
),
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 = listOf(
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_links", label = "Arteria tibialis posterior links", order = 1),
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3),
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4),
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "Per Doppler (Taschendoppler)", order = 5),
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "Per Palpation", order = 6),
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
CheckItemDefinition(
id = "vitalwerte_rr",
label = "RR (systolisch/diastolisch)",
order = 10,
inputSpec = InputSpec(
pdfTemplate = "RR {0}/{1} mmHg",
fields = listOf(
InputFieldDef.Number(label = "RR systolisch", maxLength = 3),
InputFieldDef.Number(label = "RR diastolisch", maxLength = 3),
),
),
),
CheckItemDefinition(
id = "vitalwerte_puls",
label = "Puls (BPM)",
order = 11,
inputSpec = InputSpec(
pdfTemplate = "Puls {0} BPM",
fields = listOf(InputFieldDef.Number(label = "Puls (BPM)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_blutzucker",
label = "Blutzucker (mg/dl)",
order = 12,
inputSpec = InputSpec(
pdfTemplate = "Blutzucker {0} mg/dl",
fields = listOf(InputFieldDef.Number(label = "Blutzucker (mg/dl)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_spo2",
label = "SpO2 (%)",
order = 13,
inputSpec = InputSpec(
pdfTemplate = "SpO2 {0}%",
fields = listOf(InputFieldDef.Number(label = "SpO2 (%)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_temperatur",
label = "Temperatur (°C)",
order = 14,
inputSpec = InputSpec(
pdfTemplate = "Temp. {0} °C",
fields = listOf(InputFieldDef.Number(label = "Temperatur (°C)", maxLength = 4, allowDecimal = true)),
),
),
),
),
BlockDefinition(
id = "wundheilung_beeinflussende_faktoren",
title = "Wundheilung beeinflussende Faktoren",
order = 16,
items = listOf(
CheckItemDefinition(id = "wbf_infektion_keimbesiedelung", label = "Infektion/Keimbesiedelung", order = 1),
CheckItemDefinition(id = "wbf_alter", label = "Alter", order = 2),
CheckItemDefinition(id = "wbf_diabetes_mellitus", label = "Diabetes mellitus", order = 3),
CheckItemDefinition(id = "wbf_geschwaechtes_immunsystem", label = "Geschwächtes Immunsystem", order = 4),
CheckItemDefinition(id = "wbf_ernaehrungszustand_dehydration", label = "Ernährungszustand/Dehydration", order = 5),
CheckItemDefinition(id = "wbf_medikamente", label = "Medikamente", order = 6),
CheckItemDefinition(id = "wbf_rauchen", label = "Rauchen", order = 7),
CheckItemDefinition(id = "wbf_adipositas", label = "Adipositas", order = 8),
CheckItemDefinition(id = "wbf_psych_stress_schlafmangel", label = "Psych. Stress/Schlafmangel", order = 9),
CheckItemDefinition(id = "wbf_mobilitaet", label = "Mobilität", order = 10),
CheckItemDefinition(id = "wbf_lage_der_wunde", label = "Lage der Wunde", order = 11),
),
),
BlockDefinition(
id = "auswirkungen_alltag",
title = "Auswirkungen auf das alltägliche Leben",
order = 17,
items = listOf(
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "auswirkungen_alltag_schmerz", label = "Schmerz", order = 2),
CheckItemDefinition(id = "auswirkungen_alltag_eingeschraenkte_mobilitaet", label = "Eingeschränkte Mobilität", order = 3),
CheckItemDefinition(id = "auswirkungen_alltag_schlafstoerung", label = "Schlafstörung", order = 4),
CheckItemDefinition(id = "auswirkungen_alltag_koerperbildstoerung", label = "Körperbildstörung", order = 5),
CheckItemDefinition(id = "auswirkungen_alltag_geruch", label = "Geruch", order = 6),
CheckItemDefinition(id = "auswirkungen_alltag_sozialer_rueckzug", label = "Sozialer Rückzug", order = 7),
CheckItemDefinition(id = "auswirkungen_alltag_zeitaufwand", label = "Zeitaufwand", order = 8),
CheckItemDefinition(id = "auswirkungen_alltag_einschraenkung_kleidung_hygiene", label = "Einschränkung Kleidung/Hygiene", order = 9),
CheckItemDefinition(id = "auswirkungen_alltag_abhaengigkeit_von_dritten", label = "Abhängigkeit von Dritten (Pflegedienst/Ärzte/Angehörige)", order = 10),
CheckItemDefinition(id = "auswirkungen_alltag_angst", label = "Angst", order = 11),
CheckItemDefinition(id = "auswirkungen_alltag_hobbyverlust", label = "Hobbyverlust", order = 12),
CheckItemDefinition(id = "auswirkungen_alltag_berufliche_ausfaelle", label = "Berufliche Ausfälle", order = 13),
CheckItemDefinition(id = "auswirkungen_alltag_finanzielle_zusatzbelastung", label = "Finanzielle Zusatzbelastung", order = 14),
),
),
BlockDefinition(
id = "bekannte_allergie",
title = "Bekannte Allergie",
order = 18,
items = listOf(
CheckItemDefinition(id = "bekannte_allergie_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "bekannte_allergie_nein", label = "Nein", order = 2),
),
),
BlockDefinition(
id = "beratung_erfolgte",
title = "Beratung erfolgte",
order = 19,
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 = 20,
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 = 21,
items = emptyList(),
),
BlockDefinition(
id = "empfohlene_wundversorgung",
title = "Empfohlene Wundversorgung",
order = 22,
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",
order = 6,
inputSpec = InputSpec(
pdfTemplate = "Getränkte Kompresse für ca. {0} Min. auflegen",
fields = listOf(InputFieldDef.Choice(label = "Minuten", options = MINUTE_1_TO_15_OPTIONS)),
),
),
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_wundrandschutz_ja", label = "Ja", order = 15),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16),
CheckItemDefinition(
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
label = "Wie oft pro Woche?",
order = 17,
inputSpec = InputSpec(
pdfTemplate = "{0}x pro Woche",
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
),
),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, isStaticHint = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 31,
isStaticHint = true,
),
),
),
BlockDefinition(
id = "ziel",
title = "Ziel",
order = 23,
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),
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 12),
),
),
BlockDefinition(
id = "kopie_nachrichtlich_an",
title = "Kopie nachrichtlich an",
order = 24,
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 = 25,
items = emptyList(),
),
)
fun findBlock(blockDefinitionId: String): BlockDefinition? =
@@ -74,16 +610,45 @@ data class BlockDefinition(
val items: List<CheckItemDefinition>,
) {
/**
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte Freitext-Option
* dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein Bestandteil von [items],
* da die Freitext-Option kein fachlicher Katalog-Eintrag ist, sondern strukturell zu jedem
* Block gehört.
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte, generische
* Freitext-Option dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein
* Bestandteil von [items], da diese generische Freitext-Option kein fachlicher
* 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"
}
/**
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
*
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false, [inputSpec] = null).
*
* [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.
*
* [inputSpec] != null: dieser Eintrag besitzt statt eines einfachen Häkchens ein oder mehrere
* strukturierte Eingabefelder (Zahl oder Auswahlliste, z. B. "Von [ ] bis [ ] Uhr" oder
* "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
*
* [isFreeText], [isStaticHint] und [inputSpec] schließen sich fachlich gegenseitig aus (ein
* Eintrag ist entweder normale Checkbox, Freitext-Checkbox, Hinweistext oder strukturierte
* Eingabe) - es gibt aber bewusst keine Laufzeitprüfung dafür, um die Katalogdefinition einfach
* zu halten; mehrere davon gleichzeitig zu setzen ergibt schlicht keinen sinnvollen
* Katalogeintrag.
*/
data class CheckItemDefinition(
val id: String,
val label: String,
val order: Int,
val isFreeText: Boolean = false,
val isStaticHint: Boolean = false,
val inputSpec: InputSpec? = null,
)
@@ -9,12 +9,22 @@ package de.medapp.wunddoku.domain.model
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
* dargestellt werden.
*
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`,
* ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren
* PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu
* unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen.
* [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
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`; zusätzlich kann der Katalog
* selbst beliebig viele weitere `isFreeText = true`-Optionen mit eigenem, fachlichem Label
* definieren (z. B. "Wundgrund", "Erscheinung", oder die vier Empfänger bei "Kopie
* 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]).
*
* [inputSpec]: nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens strukturierte
* Eingabefelder besitzt (Zahl/Auswahlliste) - roher, codierter String, s. [InputSpecCodec.decode].
* Die eingegebenen Werte liegen weiterhin in [freeTextValue] (mehrere Felder durch ein
* Trennzeichen kombiniert, s. [InputSpec.decodeValues]/[InputSpec.encodeValues]).
*/
data class CheckItem(
val checkItemStateId: String,
@@ -24,4 +34,6 @@ data class CheckItem(
val checked: Boolean,
val isFreeText: Boolean = false,
val freeTextValue: String = "",
val isStaticHint: Boolean = false,
val inputSpec: String = "",
)
@@ -0,0 +1,155 @@
package de.medapp.wunddoku.domain.model
/**
* Ein einzelnes Eingabefeld innerhalb eines strukturierten Katalog-Eintrags (s. [InputSpec]).
*
* Bewusst nur zwei Feldarten - genau das, was die Kundenvorgaben bisher benötigen:
* - [Number]: Zahleneingabe mit maximaler Zeichenzahl (z. B. Länge/Breite/Tiefe, Blutdruck)
* - [Choice]: Auswahl aus einer festen Liste (z. B. Uhrzeit-Position 1-12, Tiefe in 0,5-cm-Schritten)
*/
sealed interface InputFieldDef {
val label: String
/**
* @param maxLength maximale Zeichenzahl (bei [allowDecimal] inklusive des Dezimaltrennzeichens)
* @param allowDecimal erlaubt genau ein Dezimalkomma (Punkt wird beim Tippen zu Komma)
*/
data class Number(
override val label: String,
val maxLength: Int,
val allowDecimal: Boolean = false,
) : InputFieldDef
data class Choice(
override val label: String,
val options: List<String>,
) : InputFieldDef
}
/**
* Beschreibt einen Katalog-Eintrag, der statt eines einfachen Häkchens (oder Freitexts) ein oder
* mehrere strukturierte Eingabefelder besitzt - z. B. "Von [3] bis [5] Uhr" oder
* "Länge [ ] Breite [ ] Tiefe [ ]".
*
* Der Eintrag bleibt trotzdem eine ganz normale Checkbox-Zeile (s. CheckItem.checked): Sobald in
* einem Feld etwas eingegeben/gewählt wird, wird das Häkchen automatisch gesetzt (s. UI). Dadurch
* greifen BlockEditedPolicy und die "nur angehakte Elemente"-Regel im PDF-Export unverändert -
* es gibt keine zweite "bearbeitet"-Logik für diesen Eintragstyp.
*
* [pdfTemplate] bestimmt, wie der Eintrag im PDF ausgegeben wird; `{0}`, `{1}` ... werden durch die
* Werte der Felder (in Reihenfolge von [fields]) ersetzt, z. B. `"Von {0} bis {1} Uhr"`.
*
* Die Beschreibung wird beim Anlegen einer Dokumentation zusammen mit dem Eintrag gespeichert
* (Snapshot, s. [InputSpecCodec]) - spätere Katalogänderungen (andere Auswahlwerte, andere
* Textvorlage) verändern bestehende Dokumentationen dadurch nicht rückwirkend.
*/
data class InputSpec(
val pdfTemplate: String,
val fields: List<InputFieldDef>,
) {
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
fun decodeValues(stored: String): List<String> {
val parts = if (stored.isEmpty()) emptyList() else stored.split(VALUE_SEPARATOR)
return List(fields.size) { index -> parts.getOrElse(index) { "" } }
}
/** Fasst die Einzelwerte der Felder zu dem einen String zusammen, der in der Datenbank steht. */
fun encodeValues(values: List<String>): String = values.joinToString(VALUE_SEPARATOR.toString())
/** true, sobald mindestens ein Feld einen Wert enthält. */
fun hasAnyValue(stored: String): Boolean = decodeValues(stored).any { it.isNotBlank() }
/**
* Text für den PDF-Export: [pdfTemplate] mit den eingesetzten Feldwerten. Ein noch leeres
* Feld erscheint als "–", damit erkennbar bleibt, dass dort bewusst nichts gewählt wurde.
*/
fun formatForPdf(stored: String): String {
var result = pdfTemplate
decodeValues(stored).forEachIndexed { index, value ->
result = result.replace("{$index}", value.ifBlank { EMPTY_VALUE_PLACEHOLDER })
}
return result
}
companion object {
/** Trennzeichen zwischen den Werten mehrerer Felder (Steuerzeichen, kommt in Zahlen/Auswahlwerten nie vor). */
const val VALUE_SEPARATOR: Char = '\u001F'
private const val EMPTY_VALUE_PLACEHOLDER = "–"
}
}
/**
* (De-)Serialisierung von [InputSpec] in einen einzelnen String für die Datenbankspalte
* `check_item_states.inputSpec`. Bewusst ein schlichtes eigenes Format mit Steuerzeichen als
* Trennern statt JSON: reines Kotlin ohne Android-Klassen (jederzeit in normalen JUnit-Tests
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
* Textvorlagen und Auswahlwerten nicht vor.
*
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ...`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
* `C␟label␟option␝option␝...`.
*/
object InputSpecCodec {
private const val RECORD_SEPARATOR = '\u001E'
private const val UNIT_SEPARATOR = '\u001F'
private const val GROUP_SEPARATOR = '\u001D'
fun encode(spec: InputSpec): String {
val records = mutableListOf(spec.pdfTemplate)
for (field in spec.fields) {
records += when (field) {
is InputFieldDef.Number -> listOf(
"N",
field.label,
field.maxLength.toString(),
if (field.allowDecimal) "1" else "0",
).joinToString(UNIT_SEPARATOR.toString())
is InputFieldDef.Choice -> listOf(
"C",
field.label,
field.options.joinToString(GROUP_SEPARATOR.toString()),
).joinToString(UNIT_SEPARATOR.toString())
}
}
return records.joinToString(RECORD_SEPARATOR.toString())
}
/** Liefert `null` für einen leeren String (Eintrag ohne strukturierte Felder) oder bei nicht lesbaren Daten. */
fun decode(encoded: String): InputSpec? {
if (encoded.isEmpty()) return null
val records = encoded.split(RECORD_SEPARATOR)
if (records.size < 2) return null
val fields = records.drop(1).mapNotNull { record ->
val attributes = record.split(UNIT_SEPARATOR)
when (attributes.firstOrNull()) {
"N" -> {
val maxLength = attributes.getOrNull(2)?.toIntOrNull()
if (attributes.size >= 4 && maxLength != null) {
InputFieldDef.Number(
label = attributes[1],
maxLength = maxLength,
allowDecimal = attributes[3] == "1",
)
} else {
null
}
}
"C" -> if (attributes.size >= 3) {
InputFieldDef.Choice(
label = attributes[1],
options = attributes[2].split(GROUP_SEPARATOR),
)
} else {
null
}
else -> null
}
}
if (fields.isEmpty()) return null
return InputSpec(pdfTemplate = records.first(), fields = fields)
}
}
@@ -5,15 +5,18 @@ import java.time.Instant
/**
* Eine digitale Unterschrift zu einer Wunddokumentation.
*
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später
* mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
* (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen
* App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11.
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit beliebig
* viele Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
* (1:n-Beziehung, MEDIA-004). [fileName] verweist auf eine PNG-Datei im internen App-Speicher
* (kein BLOB in SQLite). [signerName] ist ein historischer Snapshot: spätere Änderungen an
* eventuellen Wundversorger-Stammdaten dürfen bereits gespeicherte Signaturen nicht rückwirkend
* verändern. [orderIndex] bestimmt die stabile Anzeige-/Export-Reihenfolge mehrerer Signaturen.
*/
data class Signature(
val id: String,
val documentationId: String,
val signerName: String?,
val signerName: String,
val fileName: String,
val orderIndex: Int,
val createdAt: Instant,
)
@@ -0,0 +1,16 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
/**
* Ein zuletzt verwendeter Wundversorger samt seiner aktuellsten Signatur - reines Komfort-Cache
* für Namensvorschläge und Vorbelegung im Signatur-Dialog, KEIN Benutzer-/Account-Objekt (s.
* [de.medapp.wunddoku.data.entity.WoundCarerEntity]). Hat keinerlei Einfluss auf bereits
* gespeicherte, historische [Signature]-Snapshots einzelner Dokumentationen.
*/
data class WoundCarer(
val id: String,
val name: String,
val signatureFileName: String,
val updatedAt: Instant,
)
@@ -23,8 +23,15 @@ object BlockEditedPolicy {
* 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
* 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 {
return block.items.any { it.checked }
return block.items.any { it.checked && !it.isStaticHint }
}
}
@@ -0,0 +1,95 @@
package de.medapp.wunddoku.media
import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.exifinterface.media.ExifInterface
/**
* Reine, von Android-Kontext (Storage/ContentResolver) unabhängige Bildverarbeitungs-
* Hilfsfunktionen - bewusst getrennt von [PhotoStorage], das für die Dateisystem-/
* ContentResolver-Koordination zuständig ist. Dadurch einzeln testbar (Unit-Test ohne
* Instrumentierung möglich, da nur `android.graphics`-Typen ohne Kontext verwendet werden).
*/
object ImageDownsampler {
/**
* Maximale längste Kante gespeicherter Fotos in Pixeln.
*
* Begründung: 2000px ist deutlich mehr, als für die Bildschirmanzeige auf Smartphone/Tablet
* nötig ist, aber klein genug, um RAM-/Speicherverbrauch bei modernen 12-48-Megapixel-
* Smartphone-Fotos drastisch zu reduzieren (ein 48-MP-Foto hätte z. B. ~8000x6000px - mehr
* als 60x so viele Pixel wie 2000px auf der langen Kante). 2000px reicht außerdem für eine
* spätere PDF-Einbettung in guter Druck-/Bildschirmqualität, ohne Rohaufnahmen dauerhaft in
* voller Auflösung vorzuhalten.
*/
const val MAX_STORED_DIMENSION_PX = 2000
/**
* Zielgröße für Vorschaubilder in der Fotoübersicht der Dokumentationsbearbeitung. Deutlich
* kleiner als die gespeicherte Datei, damit beim Rendern eines Fotogitters nicht
* versehentlich für jede Kachel die volle 2000px-Version decodiert wird.
*/
const val THUMBNAIL_MAX_DIMENSION_PX = 300
/**
* JPEG-Qualität beim Speichern (0-100).
*
* Begründung: 90 ist bewusst konservativ hoch gewählt. Die Bilder werden später Bestandteil
* medizinischer Dokumentations-PDFs (Wundränder, Verfärbungen, Größenverhältnisse müssen
* erkennbar bleiben) - eine aggressivere Kompression (z. B. 70-80) würde solche feinen
* Details sichtbar beeinträchtigen. 90 reduziert die Dateigröße gegenüber verlustfreier
* Speicherung trotzdem spürbar.
*/
const val JPEG_QUALITY = 90
/**
* Berechnet den `inSampleSize`-Wert für `BitmapFactory.Options`, mit dem ein Bild beim
* Decodieren bereits speicherschonend heruntergerechnet gelesen wird (Zweierpotenz-
* Downsampling direkt im Decoder), statt es vollständig in Originalauflösung zu laden und
* erst danach zu skalieren - das ist der von Android empfohlene Weg, um
* OutOfMemoryError bei großen Fotos zu vermeiden.
*/
fun computeInSampleSize(sourceWidth: Int, sourceHeight: Int, targetMaxDimension: Int): Int {
if (sourceWidth <= 0 || sourceHeight <= 0 || targetMaxDimension <= 0) return 1
var inSampleSize = 1
val largestSourceDimension = maxOf(sourceWidth, sourceHeight)
if (largestSourceDimension > targetMaxDimension) {
val halfDimension = largestSourceDimension / 2
while (halfDimension / inSampleSize >= targetMaxDimension) {
inSampleSize *= 2
}
}
return inSampleSize
}
/**
* Wendet die EXIF-Ausrichtung [exifOrientation] auf [bitmap] an, sodass das Ergebnis bereits
* in der visuell korrekten Ausrichtung vorliegt (viele Smartphone-Kameras schreiben Fotos
* sensor-nativ und speichern die tatsächliche Ausrichtung nur als EXIF-Metadatum).
*
* Liefert bei `ORIENTATION_NORMAL`/`ORIENTATION_UNDEFINED` (inkl. fehlender EXIF-Daten,
* s. [PhotoStorage]) dieselbe Bitmap-Instanz unverändert zurück - kein unnötiges Neu-
* Rendern. Die aufrufende Stelle ist dafür verantwortlich, das Original zu recyceln, falls
* eine neue, rotierte Instanz zurückgegeben wurde und das Original nicht mehr gebraucht wird.
*/
fun applyExifOrientation(bitmap: Bitmap, exifOrientation: Int): Bitmap {
val matrix = Matrix()
when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f)
matrix.postScale(-1f, 1f)
}
else -> return bitmap // NORMAL oder UNDEFINED - keine Korrektur nötig
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
}
@@ -1,65 +1,292 @@
package de.medapp.wunddoku.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
import kotlin.math.roundToInt
/**
* Verwaltet die physischen Bilddateien im internen App-Speicher (context.filesDir/photos/).
* Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation:
*
* WICHTIG (Phase-Hinweis): Diese Implementierung kopiert Bilder in Phase 2 zunächst
* unverändert (1:1-Kopie der ausgewählten Datei). Downsampling großer Smartphone-Fotos und
* EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der
* Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten,
* dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern.
* ```
* filesDir/
* └── photos/
* └── <documentationId>/
* ├── <uuid>.jpg
* └── ...
* ```
*
* Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung
* einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21).
* Ein eigenes Unterverzeichnis pro Dokumentation (statt eines einzigen flachen Ordners für alle
* Fotos) hat zwei konkrete Vorteile: (1) Löschen einer kompletten Dokumentation kann das
* gesamte Verzeichnis in einem Schritt entfernen ([deleteAll]), statt Einzeldateien
* durchzugehen; (2) beim Fortsetzen einer Dokumentation mit kopierten Fotos landen die Kopien
* physisch getrennt vom Original in einem eigenen Verzeichnis - Löschen eines Fotos in der
* einen Dokumentation kann das Foto der anderen dadurch strukturell nicht berühren.
*
* Dateinamen sind durchgehend zufällige UUIDs, unabhängig vom ursprünglichen Dateinamen des
* importierten Fotos - vermeidet Kollisionen und verhindert, dass ein möglicherweise
* sensibler/persönlicher Originaldateiname ins interne Dateisystem übernommen wird.
*
* Alle Ein-/Ausgabeoperationen laufen über [Dispatchers.IO], nie auf dem Hauptthread.
*/
interface PhotoStorage {
/** Kopiert das Bild von [sourceUri] in den internen Speicher und liefert den relativen Dateinamen. */
suspend fun copyFrom(sourceUri: Uri): String
/**
* Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei und liefert
* deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue
* DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das
* Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen.
* Importiert ein vom Android Photo Picker ausgewähltes Bild: liest es speicherschonend
* (Downsampling auf [ImageDownsampler.MAX_STORED_DIMENSION_PX] längster Kante über
* `BitmapFactory.Options.inSampleSize`, ohne das Originalbild vollständig in den Speicher
* zu laden), korrigiert die EXIF-Ausrichtung und speichert das Ergebnis als neue,
* eigenständige JPEG-Datei im internen Speicher. Liefert den relativen Dateinamen
* (ausschließlich der documentationId, s. [resolve]).
*
* Wirft eine Exception, wenn das Bild nicht gelesen/decodiert werden kann. Aufrufer sind
* dafür verantwortlich, das pro Bild abzufangen, damit ein einzelnes fehlerhaftes Bild bei
* Mehrfachauswahl nicht den gesamten Importvorgang abbricht.
*/
suspend fun duplicate(fileName: String): String
suspend fun importFrom(documentationId: String, sourceUri: Uri): String
/** Löscht die physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
suspend fun delete(fileName: String)
/**
* Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei in einer
* ANDEREN Dokumentation ([targetDocumentationId]). Wird bei "Dokumentation fortsetzen" mit
* `copyPhotos = true` benötigt: Eine neue DB-Zeile, die auf dieselbe Datei wie das Original
* zeigt, wäre falsch - sonst würde Löschen des Fotos in einer Dokumentation es auch in der
* anderen entfernen.
*/
suspend fun duplicate(sourceDocumentationId: String, sourceFileName: String, targetDocumentationId: String): String
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen (z. B. zur Anzeige). */
fun resolve(fileName: String): File
/** Löscht eine einzelne physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
suspend fun delete(documentationId: String, fileName: String)
/**
* Löscht das gesamte Fotoverzeichnis einer Dokumentation samt aller enthaltenen Dateien in
* einem Schritt. Verwendet, wenn die komplette Dokumentation gelöscht wird - Room-Cascade
* entfernt zwar die `PhotoEntity`-Zeilen, aber keine Dateien aus `filesDir`, das übernimmt
* ausschließlich diese Methode.
*/
suspend fun deleteAll(documentationId: String)
/**
* Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. Wird von
* MEDIA-001/002 selbst nicht für den Zugriff von außen benötigt (Compose greift nicht
* direkt auf Dateien zu, s. [decodeThumbnail]), steht aber für die spätere PDF-
* Fotoanhang-Funktionalität bereit, ohne diese hier vorwegzunehmen.
*/
fun resolve(documentationId: String, fileName: String): File
/**
* Decodiert eine speicherschonende Vorschauversion (max. [maxDimensionPx] längste Kante)
* der gespeicherten Datei, statt die volle gespeicherte Auflösung zu laden - wichtig, damit
* ein Fotogitter mit vielen Bildern nicht für jede Kachel unnötig viel Speicher belegt.
* Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz, z. B.
* wenn eine Datei nachträglich außerhalb der App entfernt wurde).
*/
suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap?
/**
* Decodiert die gespeicherte Datei proportional eingepasst in eine Box von höchstens
* [maxWidthPx] × [maxHeightPx] Pixeln, OHNE Hochskalieren über die native Auflösung hinaus
* (PDF-004: Fotoanhang). Läuft zweistufig:
*
* 1. Grobe, speicherschonende Decodierung über `BitmapFactory.Options.inSampleSize`
* (Zweierpotenz-Downsampling direkt im Decoder) - liefert ein Bitmap, das mindestens so
* groß wie die exakte Zielgröße ist, aber wegen der Zweierpotenz-Beschränkung von
* `inSampleSize` bei Quellbildern nahe der Zielgröße (z. B. nahe dem Import-Limit
* [ImageDownsampler.MAX_STORED_DIMENSION_PX]) oft noch keine ausreichende Reduktion
* bewirkt.
* 2. Exakte Skalierung dieses groben Zwischenergebnisses auf die tatsächliche Zielgröße
* (`Bitmap.createScaledBitmap`). Das Zwischenergebnis aus Schritt 1 wird direkt danach
* freigegeben (`recycle()`) - das ist unproblematisch, da es zu diesem Zeitpunkt noch
* nirgends (insbesondere nicht an `PdfDocument`) weitergereicht wurde. Nur das Ergebnis
* aus Schritt 2 wird von der aufrufenden Stelle tatsächlich in die PDF gezeichnet.
*
* Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz).
*/
suspend fun decodeFitted(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap?
}
class InternalPhotoStorage(private val context: Context) : PhotoStorage {
private val photosDir: File by lazy {
private val photosRootDir: File by lazy {
File(context.filesDir, "photos").apply { mkdirs() }
}
override suspend fun copyFrom(sourceUri: Uri): String {
private fun documentationDirPath(documentationId: String): File =
File(photosRootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File =
File(documentationDirPath(documentationId), fileName)
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
// 1) Nur die Abmessungen ermitteln (inJustDecodeBounds), ohne Pixel zu laden.
//
// WICHTIG: BitmapFactory.decodeStream(...) liefert bei inJustDecodeBounds = true
// IMMER null zurück (es befüllt nur boundsOptions, gibt aber nie ein Bitmap zurück).
// Die Null-Prüfung muss sich deshalb auf den STREAM selbst beziehen, nicht auf das
// (stets null-wertige) Ergebnis von decodeStream() - sonst würde jeder Import-
// Versuch fälschlich als "Bild konnte nicht geöffnet werden" fehlschlagen, komplett
// unabhängig vom tatsächlichen Bild.
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
val boundsStream = resolver.openInputStream(sourceUri)
?: error("Bild konnte nicht geöffnet werden (Uri nicht mehr gültig?).")
boundsStream.use { input -> BitmapFactory.decodeStream(input, null, boundsOptions) }
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
error("Bildabmessungen konnten nicht ermittelt werden - vermutlich kein unterstütztes Bildformat.")
}
val sampleSize = ImageDownsampler.computeInSampleSize(
boundsOptions.outWidth,
boundsOptions.outHeight,
ImageDownsampler.MAX_STORED_DIMENSION_PX,
)
// 2) Tatsächlich decodieren - bereits mit inSampleSize speicherschonend heruntergerechnet.
// Ein content://-InputStream lässt sich i. d. R. nicht zurückspulen, daher wird die
// Quelle für jeden Schritt separat neu geöffnet.
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
val decoded = resolver.openInputStream(sourceUri)?.use { input ->
BitmapFactory.decodeStream(input, null, decodeOptions)
} ?: error("Bild konnte nicht dekodiert werden.")
// 3) EXIF-Ausrichtung auslesen. Fehlende/nicht lesbare EXIF-Daten führen bewusst zu
// ORIENTATION_NORMAL (keine Korrektur) statt zu einem Fehler - ein Bild ohne EXIF
// ist kein Importfehler.
val orientation = resolver.openInputStream(sourceUri)?.use { input ->
runCatching {
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL
val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation)
// 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis
// speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu
// übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener
// Nebeneffekt, keine bewusste Anforderung dieser Aufgabe.
val targetDir = ensureDocumentationDir(documentationId)
val fileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(photosDir, fileName)
context.contentResolver.openInputStream(sourceUri)?.use { input ->
targetFile.outputStream().use { output -> input.copyTo(output) }
} ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri")
return fileName
File(targetDir, fileName).outputStream().use { out ->
corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
}
override suspend fun duplicate(fileName: String): String {
val newFileName = "${UUID.randomUUID()}.jpg"
resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false)
return newFileName
if (corrected !== decoded) decoded.recycle()
corrected.recycle()
fileName
}
override suspend fun delete(fileName: String) {
resolve(fileName).delete()
override suspend fun duplicate(
sourceDocumentationId: String,
sourceFileName: String,
targetDocumentationId: String,
): String = withContext(Dispatchers.IO) {
val sourceFile = resolve(sourceDocumentationId, sourceFileName)
val targetFileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
sourceFile.copyTo(targetFile, overwrite = false)
targetFileName
}
override fun resolve(fileName: String): File = File(photosDir, fileName)
override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
resolve(documentationId, fileName).delete()
Unit
}
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
documentationDirPath(documentationId).deleteRecursively()
Unit
}
override suspend fun decodeThumbnail(
documentationId: String,
fileName: String,
maxDimensionPx: Int,
): Bitmap? = withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching {
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
val sampleSize = ImageDownsampler.computeInSampleSize(
boundsOptions.outWidth,
boundsOptions.outHeight,
maxDimensionPx,
)
BitmapFactory.decodeFile(
file.absolutePath,
BitmapFactory.Options().apply { inSampleSize = sampleSize },
)
}.getOrNull()
}
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching {
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
val sourceWidth = boundsOptions.outWidth
val sourceHeight = boundsOptions.outHeight
if (sourceWidth <= 0 || sourceHeight <= 0) return@runCatching null
// Zielgröße: proportionale Einpassung in (maxWidthPx x maxHeightPx), Faktor auf
// maximal 1f begrenzt - kein Hochskalieren über die native Auflösung hinaus.
val scale = minOf(
maxWidthPx.toFloat() / sourceWidth,
maxHeightPx.toFloat() / sourceHeight,
1f,
)
val targetWidth = (sourceWidth * scale).roundToInt().coerceAtLeast(1)
val targetHeight = (sourceHeight * scale).roundToInt().coerceAtLeast(1)
// Schritt 1: grobe, speicherschonende Decodierung über inSampleSize - liefert ein
// Bitmap, das mindestens so groß wie (targetWidth, targetHeight) ist.
val inSampleSize = ImageDownsampler.computeInSampleSize(
sourceWidth,
sourceHeight,
maxOf(targetWidth, targetHeight),
)
val decodeOptions = BitmapFactory.Options().apply {
this.inSampleSize = inSampleSize
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val coarseBitmap = BitmapFactory.decodeFile(file.absolutePath, decodeOptions)
?: return@runCatching null
// Schritt 2: exakte Zielgröße. Nur nötig, wenn inSampleSize (Zweierpotenz) nicht
// bereits exakt getroffen hat - insbesondere bei Quellbildern nahe dem
// Import-Limit oft der Fall, da dort inSampleSize = 1 bleibt.
if (coarseBitmap.width == targetWidth && coarseBitmap.height == targetHeight) {
coarseBitmap
} else {
val precise = Bitmap.createScaledBitmap(coarseBitmap, targetWidth, targetHeight, true)
// Das grobe Zwischenergebnis wurde noch nirgends (insbesondere nicht an
// PdfDocument) weitergereicht - Freigabe an dieser Stelle ist unproblematisch.
if (precise !== coarseBitmap) coarseBitmap.recycle()
precise
}
}.getOrNull()
}
}
@@ -0,0 +1,457 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import android.text.TextPaint
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Konkrete [DocumentationPdfExporter]-Implementierung auf Basis von `android.graphics.pdf.PdfDocument`.
*
* Checkboxen werden bewusst NICHT über Unicode-Zeichen (☒/☐) oder sonstige Font-Glyphen
* dargestellt, sondern ausschließlich über direkte Canvas-Vektorprimitive (`drawRect`/
* `drawLine`) gezeichnet - dadurch ist das PDF geräte- und schriftartunabhängig exakt
* reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert
* ist bzw. welche Unicode-Symbole sie abdeckt.
*
* PDF-004 (Fotoanhang) - wichtige, geprüfte Speicher-Einschränkung von `PdfDocument`: Jede
* `Page` wird intern über ein `SkPicture` AUFGEZEICHNET (verifiziert anhand des AOSP-
* Quellcodes, `frameworks/base/.../PdfDocument.cpp`: `startPage()` -> `mPicture->beginRecording()`,
* `finishPage()` -> nur `endRecording()`, tatsächliches PDF-Rendering inkl. Bild-Einbettung
* erst in `write()`/`writeTo()` über `canvas->drawPicture(*page->mPicture)`). Das bedeutet: JEDE
* über `Canvas.drawBitmap(...)` gezeichnete Bitmap muss bis zum Abschluss von `writeTo()`
* gültig (nicht recycelt) bleiben - ein `Bitmap.recycle()` direkt nach dem Zeichnen einer
* einzelnen Fotoseite ist NICHT sicher. Deshalb werden alle tatsächlich gezeichneten
* Foto-Bitmaps in [export] gesammelt und erst NACH `writeTo()` (im `finally`-Block)
* freigegeben - siehe [retainedPhotoBitmaps].
*
* Der einzige verbleibende Hebel gegen hohen Speicherverbrauch ist deshalb die Zielauflösung
* pro Foto, nicht der Recycling-Zeitpunkt: Zwischenergebnisse, die selbst nie an
* `PdfDocument`/`Canvas.drawBitmap` übergeben werden (grobe `inSampleSize`-Decodierung vor der
* exakten Zielgrößen-Skalierung, s. [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]),
* dürfen dagegen sofort freigegeben werden.
*
* Speicherort: `filesDir/pdf/<documentationId>/<Dateiname>.pdf` - ausschließlich intern, keine
* MediaStore-Nutzung, kein öffentlicher Speicher.
*/
class AndroidPdfDocumentExporter(
private val context: Context,
private val photoStorage: PhotoStorage,
private val signatureStorage: SignatureStorage,
) : DocumentationPdfExporter {
companion object {
// A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm.
private const val PAGE_WIDTH = 595
private const val PAGE_HEIGHT = 842
private const val MARGIN = 40f
private const val TITLE_TEXT_SIZE = 18f
private const val SUBTITLE_TEXT_SIZE = 12f
private const val HEADER_INFO_TEXT_SIZE = 11f
private const val BLOCK_TITLE_TEXT_SIZE = 14f
private const val ITEM_TEXT_SIZE = 11f
private const val FREE_TEXT_TEXT_SIZE = 10.5f
private const val FREE_TEXT_INDENT = 20f
// PDF-003: Signaturbereich. Fachlich handschriftliche Unterschriften, keine
// kryptographische PDF-Signatur (s. Aufgabenstellung Abschnitt 5).
private const val SIGNATURE_LABEL_TEXT_SIZE = 11f
private const val SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE = 11f
private const val SIGNATURE_PLACEHOLDER_TEXT_SIZE = 10.5f
/** Feste Zielbreite des Unterschriftsbilds im PDF (deutlich schmaler als die volle
* Inhaltsbreite - eine Unterschrift muss nicht seitenbreit dargestellt werden). Bei dem
* logischen 1000x400-Signaturformat (Seitenverhältnis 2,5:1, s. Zeichenfläche) ergibt
* das eine Bildhöhe von rund 88pt. */
private const val SIGNATURE_IMAGE_TARGET_WIDTH_PT = 220f
private const val SIGNATURE_LINE_WIDTH_PT = 220f
private const val SIGNATURE_SPACING_AFTER_LABEL = 6f
private const val SIGNATURE_SPACING_AFTER_IMAGE = 4f
private const val SIGNATURE_SPACING_AFTER_LINE = 2f
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
// Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
// der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
private const val ITEM_INDENT = 10f
/**
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
* Speicherverbrauch gewählt (freigegeben nach expliziter Prüfung der `PdfDocument`-
* Speichersemantik, s. Klassendokumentation): untere Grenze des ursprünglich
* vorgeschlagenen 150-200-DPI-Bereichs, da ALLE Foto-Bitmaps bis `writeTo()` gleichzeitig
* im Speicher gehalten werden müssen (keine echte "ein Bitmap zur Zeit"-Verarbeitung mit
* dieser PDF-Engine möglich) und `ARGB_8888` statt `RGB_565` verwendet wird (keine
* Farbtiefenreduktion bei medizinischen Wundfotos).
*/
private const val PHOTO_TARGET_DPI = 150
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
// eingelesen wird), nicht die exakte Zeichenposition (die hängt zusätzlich von der
// jeweils bereits gezeichneten Bildunterschrift ab und wird von PdfPageCursor selbst
// anhand des tatsächlich verbleibenden Platzes bestimmt).
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt()
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt()
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
}
override suspend fun export(model: PdfExportModel): PdfExportResult = withContext(Dispatchers.IO) {
val targetDir = File(File(context.filesDir, "pdf"), model.documentationId)
if (!targetDir.exists() && !targetDir.mkdirs()) {
return@withContext PdfExportResult.Failure(PdfExportError.DirectoryCreationFailed(targetDir.absolutePath))
}
val targetFile = File(targetDir, model.fileName)
// Erst in eine temporäre Datei schreiben und nur bei vollständigem Erfolg auf den
// endgültigen Dateinamen umbenennen - so kann am Zielpfad selbst nie eine halbfertige
// Datei liegen bleiben, auch nicht bei einem Fehler mitten im Schreibvorgang.
val tempFile = File(targetDir, "${model.fileName}.tmp")
val pdfDocument = PdfDocument()
// Sammelt AUSSCHLIESSLICH die Bitmaps, die tatsächlich per Canvas.drawBitmap() gezeichnet
// wurden (nicht die groben inSampleSize-Zwischenergebnisse, die bereits innerhalb von
// PhotoStorage.decodeFitted() freigegeben werden). Diese müssen laut der in der
// Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig
// bleiben und werden deshalb erst im finally-Block unten recycelt.
val retainedPhotoBitmaps = mutableListOf<Bitmap>()
// Signatur-Bitmaps unterliegen exakt derselben PdfDocument-Speichersemantik wie
// Foto-Bitmaps (s. Klassendokumentation) - eigene Liste statt Wiederverwendung von
// [retainedPhotoBitmaps], damit beide Kategorien unabhängig recycelt werden können.
val retainedSignatureBitmaps = mutableListOf<Bitmap>()
try {
val paints = ExportPaints()
val cursor = PdfPageCursor(
pdfDocument = pdfDocument,
pageWidth = PAGE_WIDTH,
pageHeight = PAGE_HEIGHT,
marginLeft = MARGIN,
marginRight = MARGIN,
marginTop = MARGIN,
marginBottom = MARGIN,
)
drawHeader(cursor, model, paints)
for (block in model.blocks) {
drawBlock(cursor, block, paints)
}
// Reihenfolge PDF-003/Abschnitt 25: Dokumentationsblöcke -> Signaturen -> Fotoanhang.
if (model.signatures.isNotEmpty()) {
drawSignatures(cursor, model, paints, retainedSignatureBitmaps)
}
for ((index, photo) in model.photos.withIndex()) {
drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps)
}
cursor.finish()
FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) }
if (targetFile.exists()) targetFile.delete()
if (!tempFile.renameTo(targetFile)) {
tempFile.delete()
return@withContext PdfExportResult.Failure(
PdfExportError.WriteFailed("PDF konnte nicht am endgültigen Speicherort abgelegt werden.")
)
}
PdfExportResult.Success(targetFile)
} catch (e: Exception) {
tempFile.delete()
PdfExportResult.Failure(
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
)
} finally {
pdfDocument.close()
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
// bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist -
// in beiden Fällen werden diese Bitmaps nicht mehr gebraucht.
retainedPhotoBitmaps.forEach { runCatching { it.recycle() } }
retainedSignatureBitmaps.forEach { runCatching { it.recycle() } }
}
}
/** Bündelt alle für den Export verwendeten Paints. */
private class ExportPaints {
val title = TextPaint().apply {
isAntiAlias = true
textSize = TITLE_TEXT_SIZE
isFakeBoldText = true
color = Color.BLACK
}
val subtitle = TextPaint().apply {
isAntiAlias = true
textSize = SUBTITLE_TEXT_SIZE
color = Color.DKGRAY
}
val headerInfo = TextPaint().apply {
isAntiAlias = true
textSize = HEADER_INFO_TEXT_SIZE
color = Color.BLACK
}
val blockTitle = TextPaint().apply {
isAntiAlias = true
textSize = BLOCK_TITLE_TEXT_SIZE
isFakeBoldText = true
color = Color.BLACK
}
val item = TextPaint().apply {
isAntiAlias = true
textSize = ITEM_TEXT_SIZE
color = Color.BLACK
}
val freeText = TextPaint().apply {
isAntiAlias = true
textSize = FREE_TEXT_TEXT_SIZE
color = Color.DKGRAY
}
// PDF-003: Signaturbereich.
val signatureLabel = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_LABEL_TEXT_SIZE
color = Color.BLACK
}
val signatureNameUnderLine = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE
color = Color.BLACK
}
val signaturePlaceholder = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_PLACEHOLDER_TEXT_SIZE
color = Color.DKGRAY
}
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
val photo = Paint().apply {
isAntiAlias = true
isFilterBitmap = true
}
}
private fun drawHeader(cursor: PdfPageCursor, model: PdfExportModel, paints: ExportPaints) {
cursor.drawText("VulneraDoc", paints.title)
cursor.drawText("Wunddokumentation", paints.subtitle, spacingAfter = 8f)
val infoLines = listOf(
"Patient: ${model.patientFullName}",
"Geburtsdatum: ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}",
"Adresse: ${model.patientDisplayAddress}",
"Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}",
)
for ((index, line) in infoLines.withIndex()) {
val spacing = if (index == infoLines.lastIndex) 12f else 2f
cursor.drawText(line, paints.headerInfo, spacingAfter = spacing)
}
cursor.drawDivider(spacingAfter = 12f)
}
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f)
for (item in block.items) {
drawItem(cursor, item, paints)
}
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
}
/** Ä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) {
// Änderungswunsch: strukturierte Eingabefelder (Zahl/Auswahlliste, z. B. "Von ... bis
// ... Uhr") werden als EIN fertig formatierter Satz ausgegeben (s.
// InputSpec.formatForPdf) - kein Label+Doppelpunkt+separate Zusatzzeile wie bei
// Freitext, da die Textvorlage im Katalog bereits den vollständigen, lesbaren Satz
// vorgibt.
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
if (decodedInputSpec != null) {
cursor.drawText(
text = decodedInputSpec.formatForPdf(item.freeTextValue),
paint = paints.item,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
return
}
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
val labelText = if (item.isFreeText) "${item.label}:" else item.label
cursor.drawText(
text = labelText,
paint = paints.item,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
// Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
// leerer Freitext erzeugt keinen leeren Bereich im PDF.
if (item.isFreeText && item.freeTextValue.isNotBlank()) {
cursor.drawText(
text = item.freeTextValue,
paint = paints.freeText,
indent = FREE_TEXT_INDENT,
spacingAfter = 6f,
)
}
}
/**
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
* nach orderIndex sortiert, s. [PdfExportModel.signatures]). Anders als der Fotoanhang
* beginnt dieser Abschnitt NICHT zwingend auf einer neuen Seite - er schließt sich direkt an
* die zuletzt gezeichneten Dokumentationsblöcke an, sofern noch Platz vorhanden ist
* (Aufgabenstellung Abschnitt 25/28).
*/
private suspend fun drawSignatures(
cursor: PdfPageCursor,
model: PdfExportModel,
paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>,
) {
cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
for (signature in model.signatures) {
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
}
}
/**
* Zeichnet EINEN Signaturblock (Wundversorgername, Bild bzw. neutraler Platzhalter bei
* beschädigter/fehlender Datei, Unterschriftslinie, ausgeschriebener Name darunter) als
* unteilbare Einheit: Die Gesamthöhe wird VORAB berechnet und über
* [PdfPageCursor.ensureBlockSpace] reserviert, BEVOR die erste Teilzeichnung beginnt - reicht
* der Platz auf der aktuellen Seite nicht, wandert der komplette Block auf die nächste Seite,
* nie nur ein Teil davon (Aufgabenstellung Abschnitt 28).
*
* Bitmap-Lebensdauer: Die dekodierte Signatur-Bitmap wird - genau wie Foto-Bitmaps in
* [drawPhotoPage] - in [retainedSignatureBitmaps] gesammelt und erst nach `writeTo()`
* recycelt (s. Klassendokumentation zur PdfDocument-Speichersemantik).
*/
private suspend fun drawSignatureBlock(
cursor: PdfPageCursor,
model: PdfExportModel,
signature: PdfExportSignature,
paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>,
) {
val labelText = "Wundversorger: ${signature.signerName}"
val nameText = signature.signerName
// Fehlende/beschädigte Signaturdatei führt NICHT zum Absturz des gesamten Exports -
// andere Signaturen und Fotos werden trotzdem exportiert (Aufgabenstellung Abschnitt 31).
// Bewusst OHNE Dateipfad/technischen Fehlertext im Platzhalter.
val bitmap = runCatching {
signatureStorage.decode(documentationId = model.documentationId, fileName = signature.fileName)
}.getOrNull()
val labelHeight = cursor.measureTextHeight(labelText, paints.signatureLabel)
val imageOrPlaceholderHeight = if (bitmap != null) {
(SIGNATURE_IMAGE_TARGET_WIDTH_PT.coerceAtMost(cursor.contentWidth.toFloat())) *
bitmap.height / bitmap.width
} else {
cursor.measureTextHeight("Signatur konnte nicht geladen werden.", paints.signaturePlaceholder)
}
val nameHeight = cursor.measureTextHeight(nameText, paints.signatureNameUnderLine)
val totalBlockHeight = labelHeight + SIGNATURE_SPACING_AFTER_LABEL +
imageOrPlaceholderHeight + SIGNATURE_SPACING_AFTER_IMAGE +
1f + SIGNATURE_SPACING_AFTER_LINE + // Linie selbst ist ca. 1pt "hoch"
nameHeight + SIGNATURE_SPACING_AFTER_BLOCK
// Reserviert die GESAMTE Blockhöhe in einem Rutsch, bevor überhaupt mit dem Zeichnen
// begonnen wird - das ist der eigentliche Mechanismus hinter "atomarer Block" (s.
// Methodenkommentar oben und PdfPageCursor.ensureBlockSpace).
cursor.ensureBlockSpace(totalBlockHeight)
cursor.drawText(labelText, paints.signatureLabel, spacingAfter = SIGNATURE_SPACING_AFTER_LABEL)
if (bitmap != null) {
retainedSignatureBitmaps.add(bitmap)
cursor.drawBitmapFixedWidth(
bitmap = bitmap,
targetWidth = SIGNATURE_IMAGE_TARGET_WIDTH_PT,
paint = paints.photo,
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
)
} else {
cursor.drawText(
"Signatur konnte nicht geladen werden.",
paints.signaturePlaceholder,
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
)
}
cursor.drawShortLine(SIGNATURE_LINE_WIDTH_PT, spacingAfter = SIGNATURE_SPACING_AFTER_LINE)
cursor.drawText(nameText, paints.signatureNameUnderLine, spacingAfter = SIGNATURE_SPACING_AFTER_BLOCK)
}
/**
* Zeichnet den Fotoanhang: jedes Foto auf einer eigenen, neu begonnenen Seite (PDF-004).
* Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt
* zusätzlich eine neutrale Nummerierung ("Foto N von M") - keine Patientendaten oder
* ursprünglichen Dateinamen als Beschriftung.
*
* Erfolgreich decodierte Bitmaps werden in [retainedPhotoBitmaps] gesammelt (s. Begründung
* in der Klassendokumentation zur PdfDocument-Speichersemantik) - der Aufrufer ist dafür
* verantwortlich, diese erst NACH `writeTo()` freizugeben.
*/
private suspend fun drawPhotoPage(
cursor: PdfPageCursor,
model: PdfExportModel,
photo: PdfExportPhoto,
index: Int,
paints: ExportPaints,
retainedPhotoBitmaps: MutableList<Bitmap>,
) {
cursor.forceNewPage()
if (index == 0) {
cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f)
}
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
val bitmap = runCatching {
photoStorage.decodeFitted(
documentationId = model.documentationId,
fileName = photo.fileName,
maxWidthPx = PHOTO_MAX_WIDTH_PX,
maxHeightPx = PHOTO_MAX_HEIGHT_PX,
)
}.getOrNull()
if (bitmap != null) {
retainedPhotoBitmaps.add(bitmap)
cursor.drawBitmapFitted(bitmap, paints.photo)
} else {
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
// bleibt, dass an dieser Stelle ursprünglich ein Foto zur Dokumentation gehörte.
// Bewusst OHNE Dateipfad/technischen Fehlertext.
cursor.drawText("Foto konnte nicht geladen werden.", paints.item)
}
}
}
@@ -0,0 +1,41 @@
package de.medapp.wunddoku.pdf
import java.io.File
/**
* Erzeugt aus einem [PdfExportModel] eine PDF-Datei im internen App-Speicher.
*
* Bewusst vollständig unabhängig von Compose, Navigation, ViewModels und rohen Room-Entities -
* kennt ausschließlich diese schlanke, layoutorientierte Export-Datenstruktur. Als Interface
* mit separater Implementierung ([AndroidPdfDocumentExporter]) gehalten, damit später eine
* PDF-Engine mit Verschlüsselungs-/Signaturunterstützung ergänzt werden kann (siehe
* Aufgabenstellung "Schreibgeschützt"), ohne Repository/ViewModel/UI zu berühren.
*/
interface DocumentationPdfExporter {
suspend fun export(model: PdfExportModel): PdfExportResult
}
/** Klarer Erfolg-/Fehlerpfad statt roher, bis in die UI durchgereichter Exceptions. */
sealed interface PdfExportResult {
data class Success(val file: File) : PdfExportResult
data class Failure(val error: PdfExportError) : PdfExportResult
}
/**
* Fachliche Fehlerkategorien für den PDF-Export.
*
* [DocumentationNotFound] wird NICHT vom Exporter selbst geworfen (der kennt keine
* documentationId, nur das fertige [PdfExportModel]), sondern vom aufrufenden Repository, das
* die Dokumentation vorher lädt - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.exportPdf].
*
* Ein eigener Fehlerfall für "Patientendaten fehlen unerwartet" existiert bewusst NICHT: Da
* [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot] im Domain-Modell
* nicht-nullable ist, macht der Kotlin-Typ diesen Fall bereits zur Kompilierzeit unmöglich - ein
* zusätzlicher Laufzeit-Check wäre toter Code.
*/
sealed interface PdfExportError {
data object DocumentationNotFound : PdfExportError
data class DirectoryCreationFailed(val path: String) : PdfExportError
data class WriteFailed(val message: String) : PdfExportError
data class Unexpected(val message: String) : PdfExportError
}
@@ -0,0 +1,83 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
import java.time.ZoneId
/**
* Wandelt ein [Documentation]-Domain-Modell in das layoutfertige [PdfExportModel] um.
*
* Verwendet für die Filterung ausschließlich die bestehende [BlockEditedPolicy] (keine eigene,
* abweichende Definition von "bearbeitet") und ausschließlich die historischen Snapshot-Felder
* (titleSnapshot/labelSnapshot bzw. deren bereits gemappte Domain-Entsprechungen
* [de.medapp.wunddoku.domain.model.DocumentationBlock.title]/[de.medapp.wunddoku.domain.model.CheckItem.label]) -
* niemals den aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Ebenso werden
* ausschließlich [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot]-Daten
* verwendet, nicht der aktuelle Patientendatensatz - eine spätere Änderung der Patienten-
* Stammdaten darf ein bereits erzeugtes PDF nicht rückwirkend verändern.
*/
object DocumentationToPdfExportModelMapper {
fun map(documentation: Documentation): PdfExportModel {
val createdAtDate = documentation.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
val snapshot = documentation.patientSnapshot
val editedBlocks = documentation.blocks
.filter { BlockEditedPolicy.isEdited(it) }
.sortedBy { it.order }
.map { block ->
PdfExportBlock(
title = block.title,
// Ä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
.filter { it.checked }
.sortedBy { it.order }
.map { item ->
PdfExportItem(
label = item.label,
isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
)
},
)
}
val fileName = PdfFileNameGenerator.generate(
createdAt = createdAtDate,
firstName = snapshot.firstName,
lastName = snapshot.lastName,
birthDate = snapshot.birthDate,
)
// Explizit erneut nach orderIndex sortiert (nicht implizit auf die bereits im
// Domain-Modell vorhandene Reihenfolge verlassen) - dieselbe defensive Konsistenz wie
// bei blocks/items oben, gemäß Vorgabe "nicht Dateiname/UUID/createdAt/Dateisystem-
// Reihenfolge verwenden".
val photos = documentation.photos
.sortedBy { it.orderIndex }
.map { photo -> PdfExportPhoto(fileName = photo.fileName) }
// Explizit erneut nach orderIndex sortiert (dieselbe defensive Konsistenz wie bei
// blocks/items/photos oben) - alle gespeicherten Signaturen werden exportiert, es gibt
// keine feste Obergrenze und kein `signatures.first()` (Aufgabenstellung Abschnitt 29).
val signatures = documentation.signatures
.sortedBy { it.orderIndex }
.map { signature -> PdfExportSignature(signerName = signature.signerName, fileName = signature.fileName) }
return PdfExportModel(
documentationId = documentation.id,
fileName = fileName,
patientFullName = snapshot.fullName,
patientBirthDate = snapshot.birthDate,
patientDisplayAddress = snapshot.displayAddress,
createdAt = createdAtDate,
blocks = editedBlocks,
signatures = signatures,
photos = photos,
)
}
}
@@ -0,0 +1,69 @@
package de.medapp.wunddoku.pdf
import java.time.LocalDate
/**
* Flache, ausschließlich layoutrelevante Export-Datenstruktur für [DocumentationPdfExporter].
*
* Bewusst getrennt vom Domain-Modell [de.medapp.wunddoku.domain.model.Documentation]: Der
* Exporter soll weder Room-Entities noch domänenspezifische Feinheiten (Snapshot-Mechanik,
* IDs, Status etc.) kennen müssen, sondern ausschließlich das, was tatsächlich auf die Seite
* gezeichnet wird. Die Umwandlung übernimmt [DocumentationToPdfExportModelMapper].
*/
data class PdfExportModel(
val documentationId: String,
val fileName: String,
val patientFullName: String,
val patientBirthDate: LocalDate,
val patientDisplayAddress: String,
val createdAt: LocalDate,
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
val blocks: List<PdfExportBlock>,
/** Bereits sortiert nach orderIndex (PDF-003: Unterschriften). Reihenfolge im fertigen PDF:
* Dokumentationsblöcke -> Signaturen -> Fotoanhang. */
val signatures: List<PdfExportSignature>,
/** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */
val photos: List<PdfExportPhoto>,
)
data class PdfExportBlock(
val title: String,
/** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
* (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
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.
*
* [inputSpec]: nicht-leer, wenn dieser Eintrag strukturierte Eingabefelder besitzt (s.
* [de.medapp.wunddoku.domain.model.InputSpec]) - der Exporter formatiert in diesem Fall
* [freeTextValue] über [de.medapp.wunddoku.domain.model.InputSpec.formatForPdf] statt es als
* einfachen Zusatztext unter [label] auszugeben. */
data class PdfExportItem(
val label: String,
val isFreeText: Boolean,
val freeTextValue: String,
val inputSpec: String = "",
)
/**
* Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] -
* `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über
* [de.medapp.wunddoku.media.PhotoStorage] die intern gespeicherte Datei aufzulösen).
*/
data class PdfExportPhoto(
val fileName: String,
)
/**
* Minimale, für den Signaturbereich benötigte Referenz (PDF-003). [signerName] ist der
* historische Snapshot zum Zeitpunkt der Unterschrift - nicht der aktuelle Stand eventueller
* Wundversorger-Stammdaten. [fileName] wird analog [PdfExportPhoto] über
* [de.medapp.wunddoku.signature.SignatureStorage] aufgelöst (`documentationId` steht bereits am
* [PdfExportModel] zur Verfügung).
*/
data class PdfExportSignature(
val signerName: String,
val fileName: String,
)
@@ -0,0 +1,53 @@
package de.medapp.wunddoku.pdf
import java.text.Normalizer
import java.time.LocalDate
/**
* Zentrale, reine (keine Android-Abhängigkeit, ohne Instrumentierung testbar) Erzeugung des
* vereinbarten PDF-Dateinamensschemas:
*
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`, z. B.
* `2026-08-23_Max_Mustermann_1980-03-14.pdf`
*
* Alle Bestandteile werden dateisystemkompatibel normalisiert (keine Pfadseparatoren, keine
* ungültigen Zeichen, deutsche Umlaute transliteriert statt einfach entfernt).
*/
object PdfFileNameGenerator {
fun generate(createdAt: LocalDate, firstName: String, lastName: String, birthDate: LocalDate): String {
val datePart = createdAt.toString() // LocalDate.toString() liefert ISO-8601 (yyyy-MM-dd)
val birthPart = birthDate.toString()
val firstNamePart = normalizeNameComponent(firstName)
val lastNamePart = normalizeNameComponent(lastName)
return "${datePart}_${firstNamePart}_${lastNamePart}_${birthPart}.pdf"
}
/**
* Normalisiert einen einzelnen Namensbestandteil:
* 1. Deutsche Umlaute/ß werden transliteriert (ä→ae usw.), nicht einfach entfernt - sonst
* würden z. B. "Müller" und "Mller" im Dateinamen ununterscheidbar.
* 2. Verbleibende Diakritika (andere Sprachen) werden über Unicode-Normalisierung entfernt.
* 3. Leerzeichen werden zu Unterstrichen, alles außerhalb `[A-Za-z0-9_-]` wird entfernt -
* schließt insbesondere Pfadseparatoren (`/`, `\`) und sonstige für Dateinamen
* problematische Zeichen sicher aus.
* 4. Mehrfache Unterstriche werden zusammengefasst, führende/nachgestellte entfernt.
*/
private fun normalizeNameComponent(input: String): String {
val transliterated = transliterateGerman(input)
val withoutDiacritics = Normalizer.normalize(transliterated, Normalizer.Form.NFD)
.replace(Regex("\\p{M}+"), "")
val safe = withoutDiacritics
.replace(Regex("\\s+"), "_")
.replace(Regex("[^A-Za-z0-9_-]"), "")
.replace(Regex("_+"), "_")
.trim('_', '-')
return safe.ifBlank { "Unbekannt" }
}
private fun transliterateGerman(input: String): String = input
.replace("ä", "ae").replace("Ä", "Ae")
.replace("ö", "oe").replace("Ö", "Oe")
.replace("ü", "ue").replace("Ü", "Ue")
.replace("ß", "ss")
}
@@ -0,0 +1,99 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt alle als JPEG gerenderten PDF-Seiten (s. [PdfToImagesConverter]) GEMEINSAM an den
* Android-"Senden an"-Dialog (`ACTION_SEND_MULTIPLE`) - Änderungswunsch: "alle generierten
* Bilder über 'Senden an' direkt verschicken können", nicht Seite für Seite einzeln.
*
* Bewusst getrennt von [PdfToImagesConverter] (reine Datei-Logik): das Starten einer Activity
* ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe - exakt dieselbe Trennung wie bei
* [DocumentationPdfExporter] (Erzeugung) vs. [PdfOpener] (Anzeige).
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen Versand-Mechanismus; welche konkrete App
* (Mail, Messenger, Cloud-Speicher, ...) die Bilder entgegennimmt, entscheidet ausschließlich
* Android/der Nutzer über den System-Dialog.
*/
interface PdfImageSharer {
fun share(imageFiles: List<File>): PdfImageShareResult
}
sealed interface PdfImageShareResult {
data object Success : PdfImageShareResult
sealed interface Failure : PdfImageShareResult {
/** Es wurden keine Bilddateien übergeben - nichts zum Teilen vorhanden. */
data object NoImages : Failure
/** FileProvider konnte für mindestens eine Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Keine installierte App kann Bilder (image/jpeg) entgegennehmen. */
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfImageShareResult.Failure.toUserMessage(): String = when (this) {
PdfImageShareResult.Failure.NoImages ->
"Es sind keine Bilder zum Versenden vorhanden."
is PdfImageShareResult.Failure.UriResolutionFailed ->
"Die Bilder konnten nicht zum Versenden freigegeben werden."
PdfImageShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von Bildern installiert."
is PdfImageShareResult.Failure.Unexpected ->
"Die Bilder konnten nicht versendet werden."
}
class AndroidPdfImageSharer(private val context: Context) : PdfImageSharer {
override fun share(imageFiles: List<File>): PdfImageShareResult {
if (imageFiles.isEmpty()) return PdfImageShareResult.Failure.NoImages
val authority = "${context.packageName}.fileprovider"
val uris = try {
imageFiles.map { file -> FileProvider.getUriForFile(context, authority, file) }
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn eine Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfImageShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "image/jpeg"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// Zusätzlich zum Flag oben wird die Leseberechtigung explizit über ClipData an JEDE
// einzelne Uri gehängt - bei ACTION_SEND_MULTIPLE mit mehreren content://-Uris ist
// das der zuverlässige Weg, damit wirklich ALLE empfangenden Apps Zugriff auf ALLE
// Bilder bekommen (nicht nur auf die im Intent-Datenfeld primär referenzierte).
clipData = ClipData.newUri(context.contentResolver, "Wunddokumentation", uris.first()).apply {
for (uri in uris.drop(1)) {
addItem(ClipData.Item(uri))
}
}
}
val chooser = Intent.createChooser(intent, "Bilder senden an")
return try {
context.startActivity(chooser)
PdfImageShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfImageShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die Bilddateien wurde verweigert.")
} catch (e: Exception) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der Bilder.")
}
}
}
@@ -0,0 +1,89 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Öffnet eine bereits erzeugte PDF-Datei im auf dem Gerät verfügbaren Standard-PDF-Reader
* (PDF-005). Bewusst getrennt von [DocumentationPdfExporter]: PDF-Erzeugung (reine
* Datei-/Domain-Logik) und PDF-Anzeige (Android-Intent/Activity-Start) bleiben unterschiedliche
* Verantwortlichkeiten - der Exporter startet niemals eine Activity.
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen PDF-Viewer; welche konkrete App den
* `ACTION_VIEW`-Intent verarbeitet, entscheidet ausschließlich Android/der Nutzer.
*/
interface PdfOpener {
fun open(file: File): PdfOpenResult
}
sealed interface PdfOpenResult {
data object Success : PdfOpenResult
sealed interface Failure : PdfOpenResult {
/** Die referenzierte Datei existiert nicht mehr (z. B. zwischenzeitlich extern entfernt). */
data object FileNotFound : Failure
/** FileProvider konnte für die Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Kein installierter Reader kann application/pdf verarbeiten. */
data object NoViewerAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
/** Fachliche, für die Anzeige (z. B. Snackbar) geeignete Übersetzung eines [PdfOpenResult.Failure]. */
fun PdfOpenResult.Failure.toUserMessage(): String = when (this) {
PdfOpenResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte die Dokumentation erneut exportieren."
is PdfOpenResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zur Anzeige freigegeben werden."
PdfOpenResult.Failure.NoViewerAvailable ->
"Auf diesem Gerät ist keine App zum Anzeigen von PDF-Dateien installiert."
is PdfOpenResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht geöffnet werden."
}
class AndroidPdfOpener(private val context: Context) : PdfOpener {
override fun open(file: File): PdfOpenResult {
if (!file.exists()) {
return PdfOpenResult.Failure.FileNotFound
}
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, file)
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn die Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfOpenResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
// Ausschließlich Lese-, KEINE Schreibberechtigung für den externen Reader.
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
return try {
context.startActivity(intent)
PdfOpenResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - das würde auf Android 11+
// durch Package-Visibility-Einschränkungen ohne zusätzliche <queries>-Deklaration
// im Manifest fälschlich "kein Reader" melden können, auch wenn tatsächlich einer
// installiert ist. Der eigentliche startActivity()-Aufruf unterliegt dieser
// Einschränkung nicht - ActivityNotFoundException ist hier die zuverlässige Quelle.
PdfOpenResult.Failure.NoViewerAvailable
} catch (e: SecurityException) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Öffnen der PDF-Datei.")
}
}
}
@@ -0,0 +1,301 @@
package de.medapp.wunddoku.pdf
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.pdf.PdfDocument
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
/**
* Verwaltet Seiten, Zeichenfläche und die aktuelle vertikale Schreibposition ("Cursor") beim
* Erzeugen eines [PdfDocument]. Kapselt die komplette Seitenumbruch-Logik an einer Stelle, damit
* [AndroidPdfDocumentExporter] selbst nur noch "was" gezeichnet werden soll beschreiben muss.
*
* SEITENUMBRUCH-STRATEGIE: [drawText] zerlegt einen Text NICHT durch manuelles Verschieben/
* Clippen einzelner Zeilen eines einmal gebauten [StaticLayout]. Stattdessen wird pro
* Seiten-Abschnitt ein EIGENES, frisch aufgebautes [StaticLayout] für genau den Textausschnitt
* erzeugt, der auf die aktuelle Seite passt (Ausschnittgrenzen liegen dabei immer exakt auf
* bereits von Android selbst bestimmten Zeilengrenzen). Jeder Seiten-Abschnitt berechnet damit
* seine Baselines/Zeilenhöhen selbst und korrekt aus der StaticLayout-API - es gibt keine
* manuelle Koordinatenverschiebung einzelner Zeilen, die zu Baseline-/Zeilenhöhenfehlern führen
* könnte. Das erlaubt einen Seitenumbruch exakt zwischen zwei Zeilen und behandelt "Block
* größer als eine Seite" automatisch mit, ohne Sonderfall-Code.
*/
internal class PdfPageCursor(
private val pdfDocument: PdfDocument,
private val pageWidth: Int,
private val pageHeight: Int,
private val marginLeft: Float,
private val marginRight: Float,
private val marginTop: Float,
private val marginBottom: Float,
) {
private var pageNumber = 0
private var currentPage: PdfDocument.Page? = null
private var canvas: Canvas? = null
private var cursorY: Float = marginTop
private val dividerPaint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
}
private val footerPaint = Paint().apply {
color = Color.GRAY
textSize = 9f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
val contentWidth: Int
get() = (pageWidth - marginLeft - marginRight).toInt()
/** Verbleibende Höhe auf der aktuellen Seite ab der aktuellen Cursor-Position (PDF-004:
* Grundlage für die Einpassung des Fotos nach einer bereits gezeichneten Bildunterschrift). */
val remainingHeight: Float
get() {
startIfNeeded()
return (contentBottom() - cursorY).coerceAtLeast(0f)
}
private fun contentBottom(): Float = pageHeight - marginBottom
private fun startIfNeeded() {
if (currentPage == null) startNewPage()
}
private fun startNewPage() {
finishCurrentPageIfOpen()
pageNumber++
val info = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()
val page = pdfDocument.startPage(info)
currentPage = page
canvas = page.canvas
cursorY = marginTop
}
private fun finishCurrentPageIfOpen() {
val page = currentPage ?: return
// Seitenzahl wird direkt beim Fertigstellen dieser Seite gezeichnet (kein "Seite X von
// Y", da die Gesamtseitenzahl an dieser Stelle noch nicht bekannt ist).
canvas?.drawText("Seite $pageNumber", pageWidth / 2f, pageHeight - marginBottom / 2f, footerPaint)
pdfDocument.finishPage(page)
currentPage = null
canvas = null
}
/**
* Erzwingt den Beginn einer neuen Seite, unabhängig davon, wie viel Platz auf der aktuellen
* Seite noch verfügbar wäre (PDF-004: jedes Foto beginnt zwingend auf einer eigenen neuen
* Seite, nie im verbleibenden Platz einer Dokumentations- oder vorherigen Fotoseite). Die
* Seitennummerierung läuft dabei fortlaufend weiter, da [startNewPage] intern lediglich
* `pageNumber` erhöht, nie zurücksetzt.
*/
fun forceNewPage() {
startNewPage()
}
/**
* Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal
* über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der
* Seite. Setzt voraus, dass die Seite bereits begonnen wurde (i. d. R. direkt nach
* [forceNewPage] plus einer optionalen Bildunterschrift über [drawText]) - diese Methode
* selbst löst KEINEN Seitenumbruch aus, da ein Foto nicht über zwei Seiten verteilt werden
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
* hineinskaliert (kleiner, nie abgeschnitten).
*/
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) {
startIfNeeded()
val availableWidth = contentWidth.toFloat()
val availableHeight = remainingHeight
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height)
val drawWidth = bitmap.width * scale
val drawHeight = bitmap.height * scale
val left = marginLeft + (availableWidth - drawWidth) / 2f
val top = cursorY + (availableHeight - drawHeight) / 2f
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY = contentBottom()
}
/** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal
* aufgerufen werden. */
fun finish() {
finishCurrentPageIfOpen()
}
fun drawDivider(spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
startIfNeeded()
ensureSpace(spacingBefore + 1f)
cursorY += spacingBefore
canvas!!.drawLine(marginLeft, cursorY, pageWidth - marginRight, cursorY, dividerPaint)
cursorY += 1f + spacingAfter
}
/** Reserviert [height] vertikalen Platz, beginnt bei Bedarf eine neue Seite. */
private fun ensureSpace(height: Float) {
startIfNeeded()
if (cursorY + height > contentBottom()) {
startNewPage()
}
}
/**
* Öffentliches Gegenstück zu [ensureSpace] für ATOMARE Blöcke, die aus mehreren
* Einzelzeichnungen bestehen (PDF-003: ein Signaturblock aus Wundversorgername, Bild, Linie
* und Namenszeile darf laut Aufgabenstellung Abschnitt 28 nicht zwischen zwei Seiten
* zerrissen werden). Der Aufrufer berechnet VOR dem eigentlichen Zeichnen die
* Gesamthöhe des Blocks (s. [measureTextHeight] für die enthaltenen Textanteile) und
* reserviert sie hier in einem Rutsch - reicht der verbleibende Platz nicht, beginnt eine
* komplett neue Seite, BEVOR auch nur die erste Teilzeichnung des Blocks beginnt. Anders als
* [drawText] (das einen zu langen Text selbst auf mehrere Seiten verteilen würde) gibt es
* hier keine interne Aufteilung - das ist hier gewünscht, kein Sonderfall.
*/
fun ensureBlockSpace(height: Float) {
ensureSpace(height)
}
/**
* Berechnet die Gesamthöhe, die [text] beim Zeichnen mit [drawText] belegen würde, OHNE
* etwas zu zeichnen oder den Cursor zu verändern. Verwendet zur Berechnung dasselbe
* [buildLayout] wie [drawText] selbst (identischer Text/Paint/Breite), damit die hier
* gemessene und die später tatsächlich gezeichnete Höhe für ein und denselben Aufruf
* garantiert übereinstimmen - Voraussetzung dafür, dass eine vorab über [ensureBlockSpace]
* reservierte Blockhöhe beim tatsächlichen Zeichnen exakt ausreicht.
*/
fun measureTextHeight(text: String, paint: TextPaint, indent: Float = 0f): Float {
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
return buildLayout(text, paint, width).height.toFloat()
}
/**
* Zeichnet [bitmap] proportional in eine FESTE Zielbreite [targetWidth] eingepasst (Höhe
* ergibt sich aus dem Seitenverhältnis von [bitmap] - kein Zuschneiden, keine Verzerrung),
* linksbündig ab der aktuellen Cursor-Position.
*
* Bewusst ein GRUNDSÄTZLICH ANDERES Verhalten als [drawBitmapFitted] (PDF-004: füllt gezielt
* den GESAMTEN verbleibenden Platz der Seite - richtig für "genau ein Foto pro Seite"): Hier
* wird NICHT der restliche Seitenplatz ausgefüllt, sondern exakt die aus [targetWidth]
* abgeleitete Zielhöhe gezeichnet und der Cursor auch nur genau um diese Höhe
* weiterbewegt - Voraussetzung dafür, dass mehrere Signaturblöcke hintereinander auf
* derselben Seite stehen können, statt dass jeder einzelne Block wie ein Foto seine ganze
* Seite beansprucht (Aufgabenstellung Abschnitt 28: "Mehrere vollständige Signaturblöcke
* dürfen auf derselben Seite stehen, sofern genug Platz vorhanden ist").
*
* Setzt voraus, dass für den vollständigen Block (inkl. dieses Bildes) bereits vorher
* [ensureBlockSpace] aufgerufen wurde - diese Methode selbst löst KEINEN Seitenumbruch aus.
*/
fun drawBitmapFixedWidth(bitmap: Bitmap, targetWidth: Float, paint: Paint, spacingAfter: Float = 0f): Float {
startIfNeeded()
if (bitmap.width <= 0 || bitmap.height <= 0) return 0f
val drawWidth = targetWidth.coerceAtMost(contentWidth.toFloat())
val drawHeight = drawWidth * bitmap.height / bitmap.width
val left = marginLeft
val top = cursorY
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY += drawHeight + spacingAfter
return drawHeight
}
/**
* Kurze horizontale Linie fester Breite [width] (z. B. eine Unterschriftslinie), linksbündig
* ab der aktuellen Cursor-Position - im Gegensatz zu [drawDivider] NICHT über die volle
* Inhaltsbreite der Seite gezogen. Setzt wie [drawBitmapFixedWidth] ein vorheriges
* [ensureBlockSpace] für den gesamten umgebenden Block voraus.
*/
fun drawShortLine(width: Float, spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
startIfNeeded()
cursorY += spacingBefore
canvas!!.drawLine(marginLeft, cursorY, marginLeft + width, cursorY, dividerPaint)
cursorY += spacingAfter
}
/**
* Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert
* über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach
* rechts (z. B. für eingerückten Freitext unter einer Checkbox-Zeile).
*
* [leadingMark], falls gesetzt, wird EINMALIG unmittelbar vor der allerersten gezeichneten
* Textzeile aufgerufen - Aufrufparameter sind (Canvas, linke X-Position vor dem Einzug,
* Geräte-Y-Oberkante, Geräte-Y-Unterkante) der ersten Zeile. Dient z. B. dazu, links neben
* dem Text ein Checkbox-Symbol zu zeichnen (s. [AndroidPdfDocumentExporter]). Kästchen und
* erste Zeile werden dabei als untrennbare Einheit behandelt: Reicht der Platz auf der
* aktuellen Seite nicht einmal für die erste Zeile, wird vorher auf eine neue Seite
* gewechselt - das Kästchen kann also niemals von seiner Zeile getrennt auf eine andere
* Seite geraten.
*/
fun drawText(
text: String,
paint: TextPaint,
indent: Float = 0f,
spacingAfter: Float = 0f,
leadingMark: ((canvas: Canvas, left: Float, top: Float, bottom: Float) -> Unit)? = null,
) {
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
val fullLayout = buildLayout(text, paint, width)
var lineIndex = 0
val lineCount = fullLayout.lineCount
var isFirstLine = true
while (lineIndex < lineCount) {
startIfNeeded()
val remaining = contentBottom() - cursorY
// Ermittelt, wie viele aufeinanderfolgende Zeilen ab lineIndex noch auf die
// aktuelle Seite passen (Ausschnittgrenzen liegen exakt auf Zeilengrenzen).
var endLineExclusive = lineIndex
var usedHeight = 0f
while (endLineExclusive < lineCount) {
val lineHeight = (fullLayout.getLineBottom(endLineExclusive) - fullLayout.getLineTop(endLineExclusive)).toFloat()
if (usedHeight + lineHeight > remaining) break
usedHeight += lineHeight
endLineExclusive++
}
if (endLineExclusive == lineIndex) {
// Nicht einmal eine einzige Zeile passt mehr auf die aktuelle Seite -> neue
// Seite beginnen und denselben Ausschnitt erneut versuchen.
startNewPage()
continue
}
val charStart = fullLayout.getLineStart(lineIndex)
val charEnd = fullLayout.getLineEnd(endLineExclusive - 1)
// Frisches StaticLayout NUR für diesen Seiten-Abschnitt - Android berechnet Zeilen/
// Baselines dafür vollständig neu, beginnend bei lokal y = 0 für die erste Zeile
// dieses Abschnitts. Keine manuelle Koordinatenübernahme aus fullLayout nötig.
val chunkLayout = buildLayout(text.substring(charStart, charEnd), paint, width)
val c = canvas!!
c.save()
c.translate(marginLeft + indent, cursorY)
chunkLayout.draw(c)
c.restore()
if (isFirstLine && leadingMark != null) {
val firstLineHeight = (chunkLayout.getLineBottom(0) - chunkLayout.getLineTop(0)).toFloat()
leadingMark(c, marginLeft, cursorY, cursorY + firstLineHeight)
isFirstLine = false
}
cursorY += usedHeight
lineIndex = endLineExclusive
}
cursorY += spacingAfter
}
private fun buildLayout(text: String, paint: TextPaint, width: Int): StaticLayout {
val safeWidth = width.coerceAtLeast(1)
return StaticLayout.Builder.obtain(text, 0, text.length, paint, safeWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0f, 1.05f)
.setIncludePad(false)
.build()
}
}
@@ -0,0 +1,72 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt die fertige PDF-Datei selbst (statt in JPEG-Seiten umgewandelt, s.
* [PdfToImagesConverter]/[PdfImageSharer]) an den Android-"Senden an"-Dialog
* (`ACTION_SEND`, `application/pdf`) - Änderungswunsch: Nutzer soll beim Antippen des
* Teilen-Buttons wählen können, ob als JPG ODER als PDF geteilt wird.
*
* Bewusst getrennt von [PdfOpener] (öffnet dieselbe Datei stattdessen per `ACTION_VIEW` in
* einem PDF-Reader) - unterschiedliche Nutzerabsicht (ansehen vs. weitergeben), auch wenn
* beide dieselbe Datei über denselben FileProvider-Pfad freigeben.
*/
interface PdfSharer {
fun share(pdfFile: File): PdfShareResult
}
sealed interface PdfShareResult {
data object Success : PdfShareResult
sealed interface Failure : PdfShareResult {
data class UriResolutionFailed(val message: String) : Failure
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfShareResult.Failure.toUserMessage(): String = when (this) {
is PdfShareResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zum Versenden freigegeben werden."
PdfShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von PDFs installiert."
is PdfShareResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht versendet werden."
}
class AndroidPdfSharer(private val context: Context) : PdfSharer {
override fun share(pdfFile: File): PdfShareResult {
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, pdfFile)
} catch (e: IllegalArgumentException) {
return PdfShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "PDF senden an")
return try {
context.startActivity(chooser)
PdfShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der PDF-Datei.")
}
}
}
@@ -0,0 +1,117 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
/**
* Rendert eine bereits fertig erzeugte PDF-Datei (s. [DocumentationPdfExporter]) seitenweise in
* einzelne JPEG-Dateien - Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können".
*
* Bewusst als eigener Schritt NACH dem PDF-Export statt als Alternative dazu: Es gibt weiterhin
* genau eine Quelle der Wahrheit für das Seitenlayout (der bestehende [PdfPageCursor]-basierte
* Export) - dieser Konverter rastert lediglich das bereits fertige Ergebnis, statt eine zweite,
* parallele Layout-Logik zu pflegen.
*
* Verwendet [android.graphics.pdf.PdfRenderer] (Android-Bordmittel seit API 21, unproblematisch
* bei minSdk 26) - keine zusätzliche Bibliothek nötig. Eine mehrseitige Wunddokumentation (Kopf/
* Blöcke, Signaturen, ggf. 30+ Fotoseiten) ergibt dabei bewusst EIN JPEG PRO SEITE, kein
* einzelnes Sammelbild - beim späteren Teilen (s. [PdfImageSharer]) werden alle Seiten
* gemeinsam als Bilder-Mehrfachauswahl übergeben.
*/
interface PdfToImagesConverter {
suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult
}
sealed interface PdfToImagesResult {
data class Success(val imageFiles: List<File>) : PdfToImagesResult
sealed interface Failure : PdfToImagesResult {
/** Die übergebene PDF-Datei existiert nicht (mehr). */
data object FileNotFound : Failure
/** PdfRenderer meldet 0 Seiten - sollte bei einem selbst erzeugten PDF nie vorkommen. */
data object EmptyDocument : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
PdfToImagesResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte zuerst die Dokumentation erneut exportieren."
PdfToImagesResult.Failure.EmptyDocument ->
"Die PDF-Datei enthält keine Seiten."
is PdfToImagesResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
}
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult =
withContext(Dispatchers.IO) {
if (!pdfFile.exists()) return@withContext PdfToImagesResult.Failure.FileNotFound
val outputDir = File(File(context.filesDir, "pdf_images"), documentationId)
// Bilder eines vorherigen Exports zuerst entfernen, damit bei einer inzwischen
// kürzeren PDF (z. B. Fotos gelöscht) keine veralteten, zu vielen Seiten-Dateien
// liegen bleiben.
outputDir.deleteRecursively()
outputDir.mkdirs()
try {
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { renderer ->
if (renderer.pageCount == 0) {
return@withContext PdfToImagesResult.Failure.EmptyDocument
}
val imageFiles = mutableListOf<File>()
for (pageIndex in 0 until renderer.pageCount) {
renderer.openPage(pageIndex).use { page ->
val scale = RENDER_DPI / POINTS_PER_INCH
val width = (page.width * scale).roundToInt().coerceAtLeast(1)
val height = (page.height * scale).roundToInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WICHTIG: PdfRenderer zeichnet nur die tatsächlich im PDF
// vorhandenen Inhalte - unbedeckte Flächen bleiben transparent
// (Alpha 0). JPEG kennt keine Transparenz; ohne dieses
// Vorab-Weißfüllen würde jede unbedeckte Fläche beim
// Komprimieren als SCHWARZ statt als weißer Seitenhintergrund
// enden (bekannte PdfRenderer-Falle, s. offizielle Android-
// Beispiele).
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val imageFile = File(outputDir, "seite_%02d.jpg".format(pageIndex + 1))
FileOutputStream(imageFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
}
bitmap.recycle()
imageFiles.add(imageFile)
}
}
PdfToImagesResult.Success(imageFiles)
}
}
} catch (e: Exception) {
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
}
}
companion object {
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
* ein für Ausdruck/Weitergabe ausreichend scharfes, aber nicht unnötig großes Bild. */
private const val RENDER_DPI = 200f
private const val POINTS_PER_INCH = 72f
private const val JPEG_QUALITY = 90
}
}
@@ -0,0 +1,29 @@
package de.medapp.wunddoku.signature
import android.graphics.Bitmap
/**
* Zweite, von der UI unabhängige Absicherung gegen das Speichern einer leeren Signatur
* (Aufgabenstellung Abschnitt 12: "Daten-/Storage-Ebene: keine leere PNG-Datei als gültige
* Signatur persistieren"). Die erste Ebene ist der deaktivierte "Speichern"-Button in der UI,
* solange keine Striche gezeichnet wurden - diese Prüfung greift zusätzlich unabhängig davon in
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature], damit ein Aufruf,
* der die UI-Absicherung umgeht (Programmierfehler, zukünftiger zweiter Aufrufer o. Ä.), nicht
* trotzdem eine leere Signatur persistiert.
*/
object SignatureBitmapValidation {
/**
* Liefert `true`, wenn [bitmap] ausschließlich reinweiße Pixel enthält, also keine
* tatsächlich gezeichnete Signatur darstellt. Die Signaturzeichenfläche rendert auf einen
* weißen Hintergrund; jeder gezeichnete Strich (auch nur teilweise, dank Anti-Aliasing an
* den Rändern) erzeugt mindestens einen Pixel, der von reinem Weiß abweicht.
*/
fun isBlank(bitmap: Bitmap): Boolean {
if (bitmap.width <= 0 || bitmap.height <= 0) return true
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val opaqueWhite = 0xFFFFFFFF.toInt()
return pixels.none { pixel -> pixel != opaqueWhite }
}
}
@@ -0,0 +1,132 @@
package de.medapp.wunddoku.signature
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
/**
* Verwaltet die physischen Signatur-PNGs im internen App-Speicher, organisiert pro
* Dokumentation - exakt dieselbe Struktur-Idee wie
* [de.medapp.wunddoku.media.PhotoStorage]:
*
* ```
* filesDir/
* └── signatures/
* └── <documentationId>/
* └── <signatureId>.png
* ```
*
* Ersetzt den ursprünglichen Platzhalter `SignatureFileStorage`, der noch flach unter
* `filesDir/signatures/<uuid>.png` speicherte (ohne Bezug zur jeweiligen Dokumentation). Ein
* eigenes Unterverzeichnis pro Dokumentation hat denselben Vorteil wie bei Fotos: [deleteAll]
* kann beim Löschen einer kompletten Dokumentation das gesamte Verzeichnis in einem Schritt
* entfernen, statt Einzeldateien anhand von DB-Zeilen durchzugehen (Aufgabenstellung
* Abschnitt 13/18).
*
* Persistiert wird ausschließlich das fertige, bereits auf das logische 1000x400-Format
* normalisierte Signaturbild als PNG (verlustfrei) - Stroke-/Pfad-Daten sind ausschließlich
* Editor-State und werden hier nie entgegengenommen (Aufgabenstellung Abschnitt 11). Alle
* Operationen laufen über [Dispatchers.IO], nie auf dem Hauptthread.
*/
interface SignatureStorage {
/**
* Speichert [bitmap] als neue PNG-Datei im dokumentationsspezifischen Verzeichnis. Liefert
* den relativen Dateinamen (ausschließlich der documentationId, s. [resolve]) - kein
* absoluter, geräteabhängiger Pfad wird persistiert (Aufgabenstellung Abschnitt 15/20).
*/
suspend fun save(documentationId: String, bitmap: Bitmap): String
/** Löscht eine einzelne physische Signaturdatei. Wirft KEINE Exception, wenn die Datei
* nicht (mehr) existiert - analog [de.medapp.wunddoku.media.PhotoStorage.delete]. */
suspend fun delete(documentationId: String, fileName: String)
/**
* Löscht das gesamte Signaturverzeichnis einer Dokumentation samt aller enthaltenen Dateien
* in einem Schritt (analog [de.medapp.wunddoku.media.PhotoStorage.deleteAll]). Verwendet
* beim Löschen der kompletten Dokumentation - unabhängig davon, welche DB-Zeilen zu diesem
* Zeitpunkt noch bekannt sind, damit keine verwaisten PNG-Dateien zurückbleiben.
*/
suspend fun deleteAll(documentationId: String)
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. */
fun resolve(documentationId: String, fileName: String): File
/**
* Decodiert die gespeicherte PNG-Datei in voller Auflösung. Kein Downsampling nötig - das
* logische Signaturformat ist mit 1000x400px bereits klein (siehe Zeichenfläche), anders
* als importierte Fotos. Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden
* kann - PDF-003 (Aufgabenstellung Abschnitt 31) zeigt in diesem Fall einen neutralen
* Platzhalter statt abzustürzen.
*/
suspend fun decode(documentationId: String, fileName: String): Bitmap?
}
/**
* Generische Implementierung, organisiert pro "Schlüssel" (bei dokumentationsbezogenen
* Signaturen: `documentationId`) in einem eigenen Wurzelverzeichnis [rootDirName]. Wird
* ZWEIMAL instanziiert (s. [de.medapp.wunddoku.di.AppContainer]):
*
* - einmal für die historischen, unveränderlichen Signaturen einzelner Dokumentationen
* (`rootDirName = "signatures"`, Schlüssel = documentationId),
* - einmal für das Wundversorger-Register mit jeweils nur der AKTUELLSTEN Signatur je Person
* (`rootDirName = "wound_carer_signatures"`, Schlüssel = woundCarerId) - s.
* [de.medapp.wunddoku.data.repository.WoundCarerRepository].
*
* Beide Verwendungen sind vom Namen her identisch aufgebaut (`filesDir/<rootDirName>/<key>/`),
* aber vollständig unabhängige Verzeichnisbäume - eine gelöschte Dokumentation räumt z. B. NIE
* das Wundversorger-Register mit auf, und umgekehrt.
*/
class InternalSignatureStorage(
private val context: Context,
private val rootDirName: String = "signatures",
) : SignatureStorage {
private val rootDir: File by lazy {
File(context.filesDir, rootDirName).apply { mkdirs() }
}
private fun documentationDirPath(documentationId: String): File =
File(rootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File =
File(documentationDirPath(documentationId), fileName)
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
withContext(Dispatchers.IO) {
val fileName = "${UUID.randomUUID()}.png"
val targetDir = ensureDocumentationDir(documentationId)
FileOutputStream(File(targetDir, fileName)).use { out ->
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
// verlangt.
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
fileName
}
override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
resolve(documentationId, fileName).delete()
Unit
}
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
documentationDirPath(documentationId).deleteRecursively()
Unit
}
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull()
}
}
@@ -1,44 +1,111 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.InputFieldDef
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.pdf.AndroidPdfImageSharer
import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.AndroidPdfSharer
import de.medapp.wunddoku.pdf.PdfImageShareResult
import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.PdfShareResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
/**
* Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2,
* Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und
* Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen
* Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5.
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
* Vor-/Zurück-Buttons und Positionsanzeige ("7 / 20"), darunter dokumentationsweit (nicht
* blockbezogen) den Fotobereich (MEDIA-001/002). Die von der Aufgabenstellung bevorzugte
* Swipe-Geste zwischen Blöcken sowie Signatur/PDF-Export folgen in späteren Phasen.
*
* Der Inhalt ist als [LazyColumn] aufgebaut (statt eines einzelnen `Column` mit
* `verticalScroll`), damit Foto-Vorschaubilder nur decodiert werden, wenn ihre Zeile tatsächlich
* sichtbar ist/wird - wichtig, da die Anzahl der Fotos einer Dokumentation nicht künstlich
* begrenzt werden soll (Aufgabenstellung MEDIA-002) und jedes zusätzliche gleichzeitig
* gehaltene Vorschaubild RAM kostet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -48,10 +115,16 @@ fun DocumentationEditScreen(
) {
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
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument
// Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche
// Absicherung für den häufigen Fall.
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
@@ -63,15 +136,82 @@ fun DocumentationEditScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Android Photo Picker (MEDIA-001): benötigt KEINE READ_MEDIA_IMAGES-/
// READ_EXTERNAL_STORAGE-Berechtigung, da das System die Auswahl übernimmt und der App nur
// Zugriff auf die konkret gewählten Bilder gewährt. `PickMultipleVisualMedia` erlaubt
// Mehrfachauswahl, `ImageOnly` beschränkt die Auswahl auf Bilder. Bricht der Nutzer den
// Picker ohne Auswahl ab, liefert der Callback eine leere Liste - das wird im ViewModel
// (onPhotosPicked) als No-Op behandelt, nicht als Fehler.
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(),
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
// Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler
// "ANZEIGEN"-Aktion (PDF-005). PdfOpener wird bewusst hier in der Compose-Schicht instanziiert
// (mit dem Activity-Context aus LocalContext, analog zum Photo-Picker-Launcher oben) - das
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
val context = LocalContext.current
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
val exportedFile = uiState.lastExportedPdfFile
// "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte
// Datei vorliegt - bei einem fehlgeschlagenen Export ist exportedFile null und die
// Snackbar zeigt keine Aktion an.
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = if (exportedFile != null) "ANZEIGEN" else null,
)
if (result == SnackbarResult.ActionPerformed && exportedFile != null) {
when (val openResult = pdfOpener.open(exportedFile)) {
PdfOpenResult.Success -> Unit
is PdfOpenResult.Failure -> snackbarHostState.showSnackbar(openResult.toUserMessage())
}
}
viewModel.dismissPdfExportMessage()
}
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s.
// DocumentationEditViewModel.shareAsImages), öffnet dieser Effekt den System-
// Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
LaunchedEffect(uiState.imagesToShare) {
val images = uiState.imagesToShare ?: return@LaunchedEffect
when (val shareResult = pdfImageSharer.share(images)) {
PdfImageShareResult.Success -> Unit
is PdfImageShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onImageShareHandled()
}
// Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s.
// DocumentationEditViewModel.shareAsPdf).
LaunchedEffect(uiState.pdfToShare) {
val pdfFile = uiState.pdfToShare ?: return@LaunchedEffect
when (val shareResult = pdfSharer.share(pdfFile)) {
PdfShareResult.Success -> Unit
is PdfShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onPdfShareHandled()
}
LaunchedEffect(uiState.shareMessage) {
val message = uiState.shareMessage ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.dismissShareMessage()
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
if (uiState.blockCount > 0) {
Text(
stringResource(
R.string.documentation_edit_position,
uiState.currentBlockIndex + 1,
R.string.documentation_edit_progress,
uiState.editedBlockCount,
uiState.blockCount,
)
)
@@ -80,9 +220,27 @@ fun DocumentationEditScreen(
}
},
actions = {
// PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die
// Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave
// gesichert, kein expliziter Speichern-Button nötig).
// PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
// erzeugt die Datei im internen Speicher (PDF-001/002).
if (uiState.pdfExportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = viewModel::exportPdf) {
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
}
}
// Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden, wahlweise
// auch als PDF selbst - Format wird per Dialog abgefragt (s. weiter unten),
// BEVOR irgendetwas exportiert wird.
if (uiState.shareInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = viewModel::requestShareFormatChoice) {
Icon(Icons.Filled.Share, contentDescription = "Teilen")
}
}
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
// Autosave gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") }
},
)
@@ -101,70 +259,562 @@ fun DocumentationEditScreen(
else -> {
val documentation = uiState.documentation
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex)
if (documentation == null || currentBlock == null) {
if (documentation == null || documentation.blocks.isEmpty()) {
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Diese Dokumentation enthält keine Blöcke.")
}
} else {
DocumentationBlockContent(
DocumentationEditContent(
modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock,
blocks = documentation.blocks,
expandedBlockId = uiState.expandedBlockId,
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
photos = documentation.photos,
signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0,
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
onPrevious = viewModel::onPreviousBlock,
onNext = viewModel::onNextBlock,
photoImportInProgress = uiState.photoImportInProgress,
photoImportError = uiState.photoImportError,
onDismissPhotoImportError = viewModel::dismissPhotoImportError,
onAddPhotosClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it },
onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap,
)
}
}
}
}
val pendingDeletePhotoId = uiState.pendingDeletePhotoId
if (pendingDeletePhotoId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeletePhoto,
title = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeletePhoto) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeletePhoto) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene
// Navigationsroute, s. Aufgabenstellung Abschnitt 7).
if (uiState.showAddSignatureDialog) {
AddSignatureDialog(
onDismiss = viewModel::dismissAddSignature,
onSave = viewModel::saveSignature,
loadDefaultWoundCarer = viewModel::getDefaultWoundCarer,
searchWoundCarers = viewModel::searchWoundCarers,
loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap,
)
}
// Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung
// Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher
// deaktiviert, solange Name oder Zeichnung leer sind.
val signatureError = uiState.signatureError
if (signatureError != null) {
AlertDialog(
onDismissRequest = viewModel::dismissSignatureError,
title = { Text(stringResource(R.string.documentation_edit_signatures_title)) },
text = { Text(signatureError) },
confirmButton = {
TextButton(onClick = viewModel::dismissSignatureError) {
Text(stringResource(R.string.action_confirm))
}
},
)
}
val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId
if (pendingDeleteSignatureId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeleteSignature,
title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeleteSignature) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteSignature) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// Änderungswunsch: Foto in Originalgröße anzeigen.
val photoToView = viewedPhoto
if (photoToView != null) {
PhotoViewerDialog(
photo = photoToView,
loadFullSize = viewModel::loadPhotoFullSize,
onDismiss = { viewedPhoto = null },
)
}
// Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons.
if (uiState.showShareFormatDialog) {
ShareFormatDialog(
onDismiss = viewModel::dismissShareFormatDialog,
onChooseImages = viewModel::shareAsImages,
onChoosePdf = viewModel::shareAsPdf,
)
}
}
/**
* Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als
* PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird
* überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) -
* kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht.
*/
@Composable
private fun ShareFormatDialog(
onDismiss: () -> Unit,
onChooseImages: () -> Unit,
onChoosePdf: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text("Teilen als", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Als Bilder (JPG) oder als PDF teilen?",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = onChooseImages) { Text("JPG") }
Spacer(Modifier.width(8.dp))
Button(onClick = onChoosePdf) { Text("PDF") }
}
}
}
}
}
/**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
* (Ä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
* sind/werden.
*/
@Composable
private fun DocumentationBlockContent(
private fun DocumentationEditContent(
modifier: Modifier = Modifier,
block: DocumentationBlock,
blocks: List<DocumentationBlock>,
expandedBlockId: String?,
onToggleBlockExpanded: (blockStateId: String) -> Unit,
photos: List<Photo>,
signatures: List<Signature>,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean,
canGoNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
photoImportInProgress: Boolean,
photoImportError: String?,
onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onPhotoClick: (Photo) -> Unit,
onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
val listState = rememberLazyListState()
// Änderungswunsch: Beim Aufklappen eines Blocks dorthin scrollen, damit sein erster Eintrag
// sichtbar wird - wichtig bei langen Formularen, wenn ein weiter unten liegender Block
// aufgeklappt wird und sein Inhalt sonst unterhalb des sichtbaren Bereichs läge. Reagiert
// NUR auf tatsächliches Aufklappen (expandedBlockId wechselt zu einer konkreten Block-ID),
// nicht auf das Zuklappen (expandedBlockId wird null) - dort gibt es nichts, wohin gescrollt
// werden müsste. Da jeder Block GENAU EIN LazyColumn-Element ist (Kopfzeile + bei Bedarf
// aufgeklappter Inhalt gemeinsam, s. [BlockAccordionItem]) und die Block-Liste die ERSTEN
// Elemente der LazyColumn sind, landet der Block-Index 1:1 auf dem passenden
// LazyColumn-Index - der Block erscheint dadurch ganz oben im sichtbaren Bereich, sein
// erster Eintrag direkt darunter.
LaunchedEffect(expandedBlockId) {
val targetIndex = blocks.indexOfFirst { it.blockStateId == expandedBlockId }
if (targetIndex >= 0) {
listState.animateScrollToItem(targetIndex)
}
}
LazyColumn(
modifier = modifier,
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(block.title, style = MaterialTheme.typography.headlineSmall)
items(blocks, key = { it.blockStateId }) { block ->
BlockAccordionItem(
block = block,
expanded = block.blockStateId == expandedBlockId,
onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
}
block.items.forEach { item ->
if (item.isFreeText) {
FreeTextCheckItemRow(
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
// darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im
// späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003).
item(key = "signatures_header") {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.documentation_edit_signatures_title),
style = MaterialTheme.typography.titleMedium,
)
OutlinedButton(onClick = onAddSignatureClick) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.documentation_edit_add_signature))
}
}
}
}
if (signatures.isEmpty()) {
item(key = "signatures_empty") {
Text(
stringResource(R.string.documentation_edit_no_signatures),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
items(signatures, key = { it.id }) { signature ->
SignatureRow(
signature = signature,
loadBitmap = loadSignatureBitmap,
onDeleteRequested = { onDeleteSignatureRequested(signature.id) },
)
}
}
item(key = "photos_header") {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(R.string.documentation_edit_photos_title), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (photoImportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.documentation_edit_add_photos))
}
}
}
}
}
if (photoImportError != null) {
item(key = "photo_import_error") {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
photoImportError,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismissPhotoImportError) { Text("OK") }
}
}
}
if (photos.isEmpty()) {
item(key = "photos_empty") {
Text(
stringResource(R.string.documentation_edit_no_photos),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
PhotoRow(
photos = row,
loadThumbnail = loadThumbnail,
onDeleteRequested = onDeletePhotoRequested,
onPhotoClick = onPhotoClick,
)
}
}
}
}
private const val PHOTO_GRID_COLUMNS = 3
@Composable
private fun PhotoRow(
photos: List<Photo>,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: (photoId: String) -> Unit,
onPhotoClick: (Photo) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo ->
PhotoThumbnail(
photo = photo,
loadThumbnail = loadThumbnail,
onDeleteRequested = { onDeleteRequested(photo.id) },
onClick = { onPhotoClick(photo) },
)
}
}
}
/**
* Eine einzelne Foto-Kachel im Fotogitter. Lädt ihre Vorschauversion selbst, transient über
* [LaunchedEffect]/`remember` (an `photo.id` gebunden) - das entstehende [Bitmap] wird
* ausschließlich lokal in dieser Composable gehalten, nicht im zentralen ViewModel-Zustand
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
* 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
private fun PhotoThumbnail(
photo: Photo,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
onClick: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
LaunchedEffect(photo.id) {
val result = runCatching { loadThumbnail(photo.documentationId, photo.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Box(
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = bitmap != null, onClick = onClick),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
loadFailed -> Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
)
else -> CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
strokeWidth = 2.dp,
)
}
IconButton(
onClick = onDeleteRequested,
modifier = Modifier.align(Alignment.TopEnd).size(28.dp),
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
tint = Color.White,
)
}
}
}
/**
* Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und
* Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre
* Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) -
* analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel.
* Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es
* hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen.
*/
@Composable
private fun SignatureRow(
signature: Signature,
loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
) {
var bitmap by remember(signature.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(signature.id) { mutableStateOf(false) }
LaunchedEffect(signature.id) {
val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.width(96.dp)
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
loadFailed -> Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
)
else -> CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
strokeWidth = 2.dp,
)
}
}
Text(
text = signature.signerName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDeleteRequested) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
)
}
}
}
/**
* 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.inputSpec.isNotEmpty() -> StructuredInputRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
} else {
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
item.isFreeText -> FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.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))
}
}
HorizontalDivider()
}
}
@@ -183,10 +833,9 @@ private fun CheckItemRow(
}
/**
* Die Freitext-Option eines Blocks (Änderungswunsch: Freitext als weitere auswählbare Option
* statt eigenes Feld am Block). Das Textfeld ist deaktiviert, solange die zugehörige Checkbox
* nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option auch als
* "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
* Die Freitext-Option eines Blocks. Das Textfeld ist deaktiviert, solange die zugehörige
* Checkbox nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option
* auch als "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
*/
@Composable
private fun FreeTextCheckItemRow(
@@ -212,3 +861,115 @@ 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,
)
}
/**
* Ein Eintrag mit strukturierten Eingabefeldern statt eines einfachen Häkchens (Änderungswunsch,
* z. B. "Von [ ] bis [ ] Uhr" oder "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
* Anders als bei [FreeTextCheckItemRow] gibt es hier KEINE eigene Checkbox zum Freischalten: die
* Felder sind immer direkt ausfüllbar, `checked` wird automatisch gesetzt, sobald mindestens ein
* Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall sonst (BlockEditedPolicy, PDF-
* Filter), nur ohne zusätzlichen Tap zum "Freischalten" der Felder.
*/
@Composable
private fun StructuredInputRow(
item: CheckItem,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) }
fun updateValue(fieldIndex: Int, newValue: String) {
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
val encoded = spec.encodeValues(newValues)
onFreeTextChanged(item.checkItemStateId, encoded)
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
}
Column(modifier = Modifier.fillMaxWidth()) {
Text(item.label, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
spec.fields.forEachIndexed { index, field ->
Box(modifier = Modifier.weight(1f)) {
when (field) {
is InputFieldDef.Number -> OutlinedTextField(
value = values.getOrElse(index) { "" },
onValueChange = { raw ->
var seenComma = false
val filtered = buildString {
for (c in raw) {
when {
c.isDigit() -> append(c)
field.allowDecimal && (c == ',' || c == '.') && !seenComma -> {
append(',')
seenComma = true
}
}
}
}.take(field.maxLength)
updateValue(index, filtered)
},
label = { Text(field.label) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
is InputFieldDef.Choice -> ChoiceField(
field = field,
selected = values.getOrElse(index) { "" },
onSelected = { updateValue(index, it) },
)
}
}
}
}
}
}
/** Einfache Auswahlliste (Tippen öffnet ein Dropdown) für [InputFieldDef.Choice]-Felder - bewusst
* ein schlichter Button + [DropdownMenu] statt `ExposedDropdownMenuBox`, um ohne zusätzliche
* experimentelle Material3-APIs auszukommen. */
@Composable
private fun ChoiceField(
field: InputFieldDef.Choice,
selected: String,
onSelected: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(selected.ifBlank { field.label }, maxLines = 1)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
field.options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelected(option)
expanded = false
},
)
}
}
}
}
@@ -1,5 +1,7 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
@@ -7,8 +9,14 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -18,14 +26,76 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
data class DocumentationEditUiState(
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 error: String? = null,
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
val photoImportInProgress: Boolean = false,
/** Zusammenfassende Fehlermeldung, falls beim letzten Importvorgang einzelne Bilder
* fehlgeschlagen sind - erfolgreich importierbare Bilder werden trotzdem übernommen. */
val photoImportError: String? = null,
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
val pendingDeletePhotoId: String? = null,
/** true, solange der "Signatur hinzufügen"-Dialog angezeigt wird (MEDIA-003). */
val showAddSignatureDialog: Boolean = false,
/** Fehlermeldung aus einer fehlgeschlagenen Signatur-Validierung (zweite, von der UI
* unabhängige Absicherungsebene in [DocumentationRepository.saveSignature] - im Normalfall
* verhindert bereits der deaktivierte "Speichern"-Button im Dialog diesen Fall). */
val signatureError: String? = null,
/** != null, solange die Löschbestätigung für eine Signatur angezeigt werden soll. */
val pendingDeleteSignatureId: String? = null,
/** true, während ein PDF-Export läuft (Ladeindikator, Button währenddessen deaktiviert). */
val pdfExportInProgress: Boolean = false,
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
val pdfExportMessage: String? = null,
/**
* Die zuletzt erfolgreich erzeugte PDF-Datei dieser Dokumentation (PDF-005: "ANZEIGEN" in
* der Snackbar). `null` sowohl vor dem ersten Export als auch nach einem fehlgeschlagenen
* Export - "ANZEIGEN" wird nur angeboten, wenn hier tatsächlich eine Datei vorliegt. Wird
* bei jedem neuen Exportversuch zunächst zurückgesetzt, damit niemals eine veraltete Datei
* referenziert werden kann, während ein neuer Export läuft.
*/
val lastExportedPdfFile: File? = null,
/** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer
* Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages],
* PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert
* wird. */
val shareInProgress: Boolean = false,
/** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - ein erfolgreicher
* Export löst dagegen KEINE Meldung aus, sondern direkt [imagesToShare] bzw. [pdfToShare]. */
val shareMessage: String? = null,
/** true, solange die Auswahl "Als JPG oder PDF teilen?" angezeigt wird (Änderungswunsch:
* Format-Auswahl beim Antippen des Teilen-Buttons, bevor irgendetwas exportiert wird). */
val showShareFormatDialog: Boolean = false,
/**
* Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder
* über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect
* beobachtet, der daraufhin den System-"Senden an"-Dialog öffnet und anschließend
* [DocumentationEditViewModel.onImageShareHandled] aufruft - das Teilen selbst (Intent/
* Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
*/
val imagesToShare: List<File>? = null,
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
* [DocumentationEditViewModel.onPdfShareHandled]. */
val pdfToShare: File? = null,
) {
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
}
/**
@@ -44,6 +114,7 @@ data class DocumentationEditUiState(
class DocumentationEditViewModel(
private val documentationId: String,
private val repository: DocumentationRepository,
private val woundCarerRepository: WoundCarerRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(DocumentationEditUiState())
@@ -127,19 +198,344 @@ class DocumentationEditViewModel(
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 ->
val lastIndex = state.blockCount - 1
if (lastIndex < 0) return@update state
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
state.copy(expandedBlockId = newExpandedId)
}
}
fun onPreviousBlock() {
/**
* Importiert die vom Android Photo Picker ausgewählten Bilder (MEDIA-001). Ein leerer
* [uris] (Nutzer hat den Picker ohne Auswahl verlassen) ist kein Fehler und wird
* stillschweigend ignoriert.
*
* Jedes Bild wird EINZELN abgefangen: Schlägt der Import eines Bildes fehl (defektes
* Format, nicht mehr lesbare Uri o. Ä.), werden die übrigen, erfolgreich importierbaren
* Bilder trotzdem übernommen - ein einzelnes fehlerhaftes Bild darf einen Mehrfachimport
* nicht komplett verhindern. Am Ende wird die Dokumentation einmalig neu geladen, damit die
* UI konsistent den tatsächlichen Datenbankstand zeigt.
*/
fun onPhotosPicked(uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch {
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
var failureCount = 0
for (uri in uris) {
try {
repository.addPhoto(documentationId, uri)
} catch (e: Exception) {
failureCount++
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
android.util.Log.w(
"DocumentationEdit",
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
)
}
}
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
state.copy(
documentation = refreshed ?: state.documentation,
photoImportInProgress = false,
photoImportError = if (failureCount > 0) {
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
} else {
null
},
)
}
}
}
fun dismissPhotoImportError() {
_uiState.update { it.copy(photoImportError = null) }
}
/** Öffnet die Löschbestätigung für ein Foto (Aufgabenstellung: "vor dem endgültigen Löschen
* soll eine Bestätigung erfolgen"). */
fun requestDeletePhoto(photoId: String) {
_uiState.update { it.copy(pendingDeletePhotoId = photoId) }
}
fun dismissDeletePhoto() {
_uiState.update { it.copy(pendingDeletePhotoId = null) }
}
fun confirmDeletePhoto() {
val photoId = _uiState.value.pendingDeletePhotoId ?: return
viewModelScope.launch {
repository.removePhoto(photoId)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
pendingDeletePhotoId = null,
)
}
}
}
/** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */
fun requestAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = true) }
}
fun dismissAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = false) }
}
/**
* Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische
* 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]).
* Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst
* NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s.
* [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern"
* bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte.
*/
fun saveSignature(signerName: String, bitmap: Bitmap) {
viewModelScope.launch {
try {
repository.saveSignature(documentationId, signerName, bitmap)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
showAddSignatureDialog = false,
)
}
} catch (e: IllegalArgumentException) {
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
}
}
}
fun dismissSignatureError() {
_uiState.update { it.copy(signatureError = null) }
}
/** Öffnet die Löschbestätigung für eine Signatur (Aufgabenstellung Abschnitt 17: Signaturen
* sind unveränderlich - "korrigieren" bedeutet immer löschen + neu erfassen). */
fun requestDeleteSignature(signatureId: String) {
_uiState.update { it.copy(pendingDeleteSignatureId = signatureId) }
}
fun dismissDeleteSignature() {
_uiState.update { it.copy(pendingDeleteSignatureId = null) }
}
fun confirmDeleteSignature() {
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
viewModelScope.launch {
repository.removeSignature(signatureId)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
pendingDeleteSignatureId = null,
)
}
}
}
/**
* Löst den PDF-Export an (PDF-001/002). Rein zum manuellen Testen der Export-Funktionalität
* gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit
* nicht Teil dieses Schritts, die erzeugte Datei liegt ausschließlich im internen
* App-Speicher. Keine rohe Exception erreicht die UI: [PdfExportResult] wird hier in eine
* kurze, verständliche Meldung übersetzt.
*/
fun exportPdf() {
viewModelScope.launch {
_uiState.update {
it.copy(pdfExportInProgress = true, pdfExportMessage = null, lastExportedPdfFile = null)
}
val result = repository.exportPdf(documentationId)
_uiState.update {
when (result) {
is PdfExportResult.Success -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF erfolgreich erstellt",
lastExportedPdfFile = result.file,
)
is PdfExportResult.Failure -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}",
lastExportedPdfFile = null,
)
}
}
}
}
fun dismissPdfExportMessage() {
_uiState.update { it.copy(pdfExportMessage = null) }
}
/**
* Öffnet die Auswahl "Als JPG oder PDF teilen?" (Änderungswunsch: Format-Auswahl beim
* Antippen des Teilen-Buttons, BEVOR irgendetwas exportiert wird - vorher wurde beim
* Antippen sofort und ausschließlich als JPG geteilt).
*/
fun requestShareFormatChoice() {
_uiState.update { it.copy(showShareFormatDialog = true) }
}
fun dismissShareFormatDialog() {
_uiState.update { it.copy(showShareFormatDialog = false) }
}
/** Nutzer hat sich für "Als JPG teilen" entschieden. */
fun shareAsImages() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndShareAsImages()
}
/** Nutzer hat sich für "Als PDF teilen" entschieden. */
fun shareAsPdf() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndSharePdf()
}
/**
* Exportiert die Dokumentation frisch als PDF und wandelt das Ergebnis anschließend
* seitenweise in JPEG-Bilder um (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden
* können"). Bewusst ein eigener, vollständiger Export-Lauf statt Wiederverwendung von
* [uiState.lastExportedPdfFile]: so spiegeln die Bilder IMMER den aktuellen Stand wider,
* unabhängig davon, ob/wann zuvor schon einmal "PDF erzeugen" angetippt wurde.
*
* Das eigentliche Teilen (Android-"Senden an"-Dialog) übernimmt NICHT diese Methode, sondern
* der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
*/
private fun exportAndShareAsImages() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
}
val exportResult = repository.exportPdf(documentationId)
val pdfFile = when (exportResult) {
is PdfExportResult.Success -> exportResult.file
is PdfExportResult.Failure -> {
_uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
return@launch
}
}
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
is PdfToImagesResult.Success -> _uiState.update {
it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
}
is PdfToImagesResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}",
)
}
}
}
}
/**
* Exportiert die Dokumentation frisch als PDF und stellt sie zum Teilen bereit
* (Änderungswunsch: Format-Auswahl "PDF" statt "JPG") - ohne die JPEG-Konvertierung, die
* geteilte Datei ist die PDF selbst. Das eigentliche Teilen übernimmt der Screen anhand von
* [DocumentationEditUiState.pdfToShare] (s. [de.medapp.wunddoku.pdf.PdfSharer]).
*/
private fun exportAndSharePdf() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, pdfToShare = null)
}
when (val exportResult = repository.exportPdf(documentationId)) {
is PdfExportResult.Success -> _uiState.update {
it.copy(shareInProgress = false, pdfToShare = exportResult.file)
}
is PdfExportResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
}
}
}
/** Wird vom Screen aufgerufen, nachdem der "Senden an"-Dialog für [DocumentationEditUiState.imagesToShare]
* geöffnet wurde (erfolgreich oder nicht) - verhindert ein erneutes Öffnen bei der nächsten
* Recomposition, solange keine neuen Bilder erzeugt wurden. */
fun onImageShareHandled() {
_uiState.update { it.copy(imagesToShare = null) }
}
/** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */
fun onPdfShareHandled() {
_uiState.update { it.copy(pdfToShare = null) }
}
fun dismissShareMessage() {
_uiState.update { it.copy(shareMessage = null) }
}
private fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
}
/**
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
* [DocumentationEditUiState]): Bitmaps sind vergleichsweise schwergewichtig und sollen nur
* transient in der jeweiligen Compose-Kachel gehalten werden, nicht dauerhaft im
* ViewModel-Zustand. Die eigentliche Datei-/Decodier-Logik bleibt vollständig in
* PhotoStorage (über das Repository) - Compose ruft ausschließlich diese suspend-Funktion
* auf und führt selbst keinen Datei-/ContentResolver-Zugriff aus.
*/
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
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
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
repository.loadSignatureBitmap(documentationId, fileName)
/** Für die Vorbelegung des "Signatur hinzufügen"-Dialogs beim Öffnen: der zuletzt
* verwendete Wundversorger app-weit (nicht auf diese Dokumentation beschränkt) - `null`,
* wenn noch nie eine Signatur gespeichert wurde. Weiterhin änderbar (Änderungswunsch). */
suspend fun getDefaultWoundCarer(): WoundCarer? = woundCarerRepository.getMostRecentlyUsed()
/** Namensvorschläge für die Autovervollständigung im Signatur-Dialog. */
suspend fun searchWoundCarers(query: String): List<WoundCarer> = woundCarerRepository.search(query)
/** Lädt die hinterlegte Signatur eines bekannten Wundversorgers (Vorbelegung bzw. nach Tippen
* auf einen Namensvorschlag) - unabhängig von [loadSignatureBitmap], das historische, pro
* Dokumentation gespeicherte Signaturen lädt. */
suspend fun loadWoundCarerSignatureBitmap(woundCarerId: String, fileName: String) =
woundCarerRepository.loadSignatureBitmap(woundCarerId, fileName)
/**
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
@@ -169,6 +565,7 @@ class DocumentationEditViewModel(
DocumentationEditViewModel(
documentationId = route.documentationId,
repository = container.documentationRepository,
woundCarerRepository = container.woundCarerRepository,
)
}
}
@@ -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)
}
}
}
}
@@ -0,0 +1,235 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.WoundCarer
import kotlinx.coroutines.launch
/**
* Dialog zum Erfassen einer neuen Signatur (MEDIA-003): Name des Wundversorgers + Zeichenfläche
* ([SignaturePad]), wie in der Aufgabenstellung (Abschnitt 7) als bevorzugte UX beschrieben -
* bewusst als Dialog INNERHALB von [de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen],
* keine eigene Navigationsroute.
*
* Änderungswunsch (Wundversorger-Register): Beim Öffnen wird automatisch der ZULETZT verwendete
* Wundversorger samt seiner zuletzt gespeicherten Signatur vorbelegt ([loadDefaultWoundCarer]) -
* bleibt aber änderbar. Während der Eingabe werden nach den ersten Buchstaben passende bekannte
* Namen als antippbare Vorschläge angezeigt ([searchWoundCarers]); ein Tippen darauf übernimmt
* Name UND zuletzt gespeicherte Signatur dieses Wundversorgers ([loadWoundCarerSignatureBitmap]).
* In diesem "Vorschau-Modus" wird die geladene Signatur als fertiges Bild angezeigt (nicht mehr
* frei bearbeitbar) - über "Neu zeichnen" kann jederzeit zu einer frischen, freihändigen
* Zeichnung für denselben Namen gewechselt werden. Wird der Name manuell so verändert, dass er
* nicht mehr zum gerade geladenen Vorschlag passt, wird die Vorschau verworfen (verhindert, dass
* versehentlich die Signatur einer anderen Person unter einem neuen Namen gespeichert wird).
*
* "Speichern" ist erst aktiv, wenn BEIDE Bedingungen erfüllt sind (erste Absicherungsebene gegen
* leere Signaturen bzw. leeren Namen, s. Aufgabenstellung Abschnitt 12 - die zweite, unabhängige
* Ebene sitzt serverseitig in
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature]):
* - der (getrimmte) Name ist nicht leer
* - es liegt entweder eine geladene Vorschau-Signatur ODER eine frisch gezeichnete Signatur vor
*/
@Composable
fun AddSignatureDialog(
onDismiss: () -> Unit,
onSave: (signerName: String, bitmap: Bitmap) -> Unit,
loadDefaultWoundCarer: suspend () -> WoundCarer?,
searchWoundCarers: suspend (query: String) -> List<WoundCarer>,
loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?,
) {
var signerName by remember { mutableStateOf("") }
var strokes by remember { mutableStateOf<List<SignatureStroke>>(emptyList()) }
var canvasSize by remember { mutableStateOf(Size.Zero) }
// != null => Vorschau-Modus: eine bereits bekannte Signatur wird als fertiges Bild gezeigt
// statt einer leeren, frei bearbeitbaren Zeichenfläche.
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Name, zu dem [previewBitmap] gehört - dient ausschließlich dazu, die Vorschau zu verwerfen,
// sobald der Name manuell auf etwas anderes geändert wird (s. Klassenkommentar).
var previewOwnerName by remember { mutableStateOf<String?>(null) }
var suggestions by remember { mutableStateOf<List<WoundCarer>>(emptyList()) }
val scope = rememberCoroutineScope()
// Einmalige Vorbelegung beim Öffnen mit dem zuletzt verwendeten Wundversorger.
LaunchedEffect(Unit) {
val default = loadDefaultWoundCarer() ?: return@LaunchedEffect
signerName = default.name
previewOwnerName = default.name
previewBitmap = loadWoundCarerSignatureBitmap(default.id, default.signatureFileName)
}
// Vorschläge nachladen UND Vorschau verwerfen, sobald der Name vom Vorschau-Eigentümer
// abweicht (z. B. weil der Nutzer den vorbelegten Namen überschreibt).
LaunchedEffect(signerName) {
if (previewOwnerName != null && signerName != previewOwnerName) {
previewBitmap = null
previewOwnerName = null
}
suggestions = if (previewOwnerName == signerName) {
emptyList()
} else {
searchWoundCarers(signerName)
}
}
val canSave = signerName.trim().isNotEmpty() && (previewBitmap != null || strokes.isNotEmpty())
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text(
stringResource(R.string.signature_dialog_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = signerName,
onValueChange = { signerName = it },
label = { Text(stringResource(R.string.signature_dialog_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(suggestions, key = { it.id }) { suggestion ->
AssistChip(
onClick = {
signerName = suggestion.name
previewOwnerName = suggestion.name
suggestions = emptyList()
scope.launch {
previewBitmap = loadWoundCarerSignatureBitmap(
suggestion.id,
suggestion.signatureFileName,
)
}
},
label = { Text(suggestion.name) },
)
}
}
}
Spacer(Modifier.height(12.dp))
val currentPreview = previewBitmap
if (currentPreview != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp)),
) {
Image(
bitmap = currentPreview.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(180.dp).padding(8.dp),
contentScale = ContentScale.Fit,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = {
previewBitmap = null
strokes = emptyList()
}) {
Text(stringResource(R.string.signature_dialog_redraw))
}
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp))
.onSizeChanged { size -> canvasSize = Size(size.width.toFloat(), size.height.toFloat()) },
) {
SignaturePad(
strokes = strokes,
onStrokesChanged = { strokes = it },
modifier = Modifier.fillMaxWidth().height(180.dp),
)
}
Text(
stringResource(R.string.signature_dialog_single_stroke_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { strokes = emptyList() }, enabled = strokes.isNotEmpty()) {
Text(stringResource(R.string.signature_dialog_reset))
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val bitmap = previewBitmap ?: renderSignatureBitmap(strokes, canvasSize)
onSave(signerName.trim(), bitmap)
},
enabled = canSave,
) {
Text(stringResource(R.string.signature_dialog_save))
}
}
}
}
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
/** Logisches Signaturformat, unabhängig von der tatsächlichen Größe der Compose-Zeichenfläche
* auf einem konkreten Gerät (Aufgabenstellung Abschnitt 10). NUR für die persistierte Bitmap
* relevant - die UI selbst darf/soll responsiv sein. */
const val SIGNATURE_LOGICAL_WIDTH = 1000
const val SIGNATURE_LOGICAL_HEIGHT = 400
/** Strichbreite in Bildschirm-dp während des Zeichnens. Die tatsächliche, im PNG persistierte
* Strichbreite ergibt sich aus [SIGNATURE_LOGICAL_STROKE_WIDTH] nach der Normalisierung. */
private const val SIGNATURE_UI_STROKE_WIDTH_PX = 5f
private const val SIGNATURE_LOGICAL_STROKE_WIDTH = 6f
/**
* Eine einzelne, zusammenhängende Zeichenbewegung (ein "Strich") in tatsächlichen
* Canvas-Pixelkoordinaten (nicht normalisiert).
*/
typealias SignatureStroke = List<Offset>
/**
* Compose-basierte Signaturzeichenfläche (MEDIA-003). Unterstützt Finger UND Stylus ohne
* Sonderbehandlung - Compose liefert beide über dieselbe Zeigereingabe-Pipeline
* ([pointerInput]/[detectDragGestures]), es gibt auf Android keinen technischen Unterschied
* zwischen einer Finger- und einer Stylus-Berührung an dieser Stelle.
*
* FACHLICHE EINSCHRÄNKUNG (Änderungswunsch): Eine gültige Signatur besteht aus GENAU EINEM
* durchgehenden Strich - ein erneutes Ansetzen (zweiter Touch-Down, nachdem bereits ein Strich
* gezeichnet wurde) verwirft die komplette bisherige Zeichnung automatisch und beginnt neu mit
* genau diesem neuen Strich. Es kann also beliebig oft neu versucht werden, aber niemals mehrere
* getrennte Striche zu EINER Signatur zusammengesetzt werden. Umgesetzt, indem [onDragEnd] die
* nach oben gemeldete Strichliste immer komplett ERSETZT (`listOf(currentStroke)`) statt an
* [strokes] anzuhängen - dadurch kann zu jedem Zeitpunkt höchstens ein abgeschlossener Strich in
* [strokes] stehen, unabhängig von der Reihenfolge der Ereignisse.
*
* Zustandslos (state hoisting): [strokes] kommt vom Aufrufer, jede Änderung wird über
* [onStrokesChanged] nach oben gemeldet.
*/
@Composable
fun SignaturePad(
strokes: List<SignatureStroke>,
onStrokesChanged: (List<SignatureStroke>) -> Unit,
modifier: Modifier = Modifier,
) {
var currentStroke by remember { mutableStateOf<SignatureStroke>(emptyList()) }
Canvas(
modifier = modifier
.background(Color.White)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset -> currentStroke = listOf(offset) },
onDrag = { change, _ ->
change.consume()
currentStroke = currentStroke + change.position
},
onDragEnd = {
// Ein "Tap" ohne tatsächliche Bewegung (< 2 Punkte) erzeugt bewusst
// keinen sichtbaren Strich - eine bereits vorhandene, gültige Signatur
// bleibt in diesem Fall unangetastet erhalten. Ein ECHTER Strich ersetzt
// dagegen immer die komplette bisherige Zeichnung (s. Klassenkommentar).
if (currentStroke.size > 1) {
onStrokesChanged(listOf(currentStroke))
}
currentStroke = emptyList()
},
onDragCancel = { currentStroke = emptyList() },
)
},
) {
for (stroke in strokes) {
drawSignatureStroke(stroke)
}
if (currentStroke.size > 1) {
drawSignatureStroke(currentStroke)
}
}
}
private fun DrawScope.drawSignatureStroke(points: SignatureStroke) {
if (points.size < 2) return
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (index in 1 until points.size) {
lineTo(points[index].x, points[index].y)
}
}
drawPath(
path = path,
color = Color.Black,
style = Stroke(
width = SIGNATURE_UI_STROKE_WIDTH_PX,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
)
}
/**
* Rendert die erfassten [strokes] (in tatsächlichen Canvas-Pixelkoordinaten der Größe
* [canvasSize]) auf ein neues, weißes Bitmap im logischen Signaturformat
* ([SIGNATURE_LOGICAL_WIDTH] x [SIGNATURE_LOGICAL_HEIGHT]) - unabhängig von der tatsächlichen
* Gerätegröße der Zeichenfläche (Aufgabenstellung Abschnitt 10: Normalisierung). Persistiert
* wird ausschließlich dieses fertige Bitmap, niemals die Stroke-Rohdaten selbst
* (Aufgabenstellung Abschnitt 11).
*/
fun renderSignatureBitmap(strokes: List<SignatureStroke>, canvasSize: Size): Bitmap {
val bitmap = Bitmap.createBitmap(SIGNATURE_LOGICAL_WIDTH, SIGNATURE_LOGICAL_HEIGHT, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
canvas.drawColor(android.graphics.Color.WHITE)
if (canvasSize.width <= 0f || canvasSize.height <= 0f) return bitmap
val scaleX = SIGNATURE_LOGICAL_WIDTH / canvasSize.width
val scaleY = SIGNATURE_LOGICAL_HEIGHT / canvasSize.height
val paint = android.graphics.Paint().apply {
isAntiAlias = true
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.STROKE
strokeWidth = SIGNATURE_LOGICAL_STROKE_WIDTH
strokeCap = android.graphics.Paint.Cap.ROUND
strokeJoin = android.graphics.Paint.Join.ROUND
}
for (stroke in strokes) {
if (stroke.size < 2) continue
val path = android.graphics.Path()
path.moveTo(stroke.first().x * scaleX, stroke.first().y * scaleY)
for (index in 1 until stroke.size) {
path.lineTo(stroke[index].x * scaleX, stroke[index].y * scaleY)
}
canvas.drawPath(path, paint)
}
return bitmap
}
+22 -3
View File
@@ -32,11 +32,30 @@
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
<string name="patient_form_save">Speichern</string>
<string name="documentation_edit_position">%1$d / %2$d</string>
<string name="documentation_edit_previous">Zurück</string>
<string name="documentation_edit_next">Weiter</string>
<string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
<string name="documentation_edit_block_edited">Block bearbeitet</string>
<string name="documentation_edit_free_text_label">Freitext</string>
<string name="documentation_edit_photos_title">Fotos</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_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_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="documentation_edit_signatures_title">Unterschriften</string>
<string name="documentation_edit_add_signature">Signatur hinzufügen</string>
<string name="documentation_edit_no_signatures">Noch keine Unterschriften vorhanden.</string>
<string name="documentation_edit_delete_signature_confirm_title">Unterschrift löschen?</string>
<string name="documentation_edit_delete_signature_confirm_text">Diese Unterschrift wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="signature_dialog_title">Signatur hinzufügen</string>
<string name="signature_dialog_name_label">Name des Wundversorgers</string>
<string name="signature_dialog_reset">Zurücksetzen</string>
<string name="signature_dialog_save">Speichern</string>
<string name="signature_dialog_redraw">Neu zeichnen</string>
<string name="signature_dialog_single_stroke_hint">Bitte in einem Zug unterschreiben, ohne abzusetzen - bei erneutem Ansetzen wird die Zeichnung zurückgesetzt.</string>
<string name="action_cancel">Abbrechen</string>
<string name="action_confirm">Bestätigen</string>
</resources>
+1
View File
@@ -7,6 +7,7 @@
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" />
</paths>
@@ -0,0 +1,151 @@
package de.medapp.wunddoku.domain.usecase
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Blockstatus-Tests: Wann gilt ein Dokumentationsblock als "bearbeitet"?
* Diese Regel steuert Fortschrittsanzeige UND PDF-Export und muss deshalb exakt stimmen.
*/
class BlockEditedPolicyTest {
private var idCounter = 0
private fun item(
checked: Boolean,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
): CheckItem {
idCounter++
return CheckItem(
checkItemStateId = "state-$idCounter",
definitionId = "def-$idCounter",
label = "Eintrag $idCounter",
order = idCounter,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
}
private fun block(vararg items: CheckItem) = DocumentationBlock(
blockStateId = "block-state",
definitionId = "block-def",
title = "Testblock",
order = 1,
items = items.toList(),
)
// --- nicht bearbeitet ----------------------------------------------------------------
@Test
fun block_ohne_eintraege_ist_nicht_bearbeitet() {
assertFalse(BlockEditedPolicy.isEdited(block()))
}
@Test
fun block_ohne_angehakten_eintrag_ist_nicht_bearbeitet() {
val b = block(item(checked = false), item(checked = false), item(checked = false, isFreeText = true))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nicht_angehakter_freitext_mit_text_zaehlt_nicht_als_bearbeitung() {
// Das Freitextfeld ist in der UI gesperrt, solange die Checkbox nicht angehakt ist.
val b = block(item(checked = false, isFreeText = true, freeTextValue = "Text ohne Haken"))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nur_hinweistexte_machen_einen_block_nicht_bearbeitet() {
// Hinweistexte sind technisch immer checked = true, aber keine Nutzereingabe.
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true, isStaticHint = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistexte_plus_nicht_angehakte_eintraege_sind_nicht_bearbeitet() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = false),
item(checked = false, isFreeText = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
// --- bearbeitet ----------------------------------------------------------------------
@Test
fun ein_angehakter_normaler_eintrag_macht_den_block_bearbeitet() {
assertTrue(BlockEditedPolicy.isEdited(block(item(checked = true))))
}
@Test
fun angehakter_freitext_zaehlt_auch_ohne_eingegebenen_text() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = ""))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_freitext_mit_text_ist_bearbeitet() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = "Rötung am Wundrand"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_eintrag_mit_strukturierter_eingabe_ist_bearbeitet() {
val b = block(item(checked = true, freeTextValue = "3", inputSpec = "spec"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistext_wird_ignoriert_sobald_ein_echter_eintrag_angehakt_ist() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true),
)
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun mehrere_angehakte_eintraege_sind_bearbeitet() {
val b = block(item(checked = true), item(checked = true), item(checked = false))
assertTrue(BlockEditedPolicy.isEdited(b))
}
// --- Zusammenspiel mit DocumentationBlock.isEdited ------------------------------------
@Test
fun documentation_block_isEdited_delegiert_an_die_policy() {
val edited = block(item(checked = true))
val untouched = block(item(checked = false), item(checked = true, isStaticHint = true))
assertEquals(BlockEditedPolicy.isEdited(edited), edited.isEdited)
assertEquals(BlockEditedPolicy.isEdited(untouched), untouched.isEdited)
assertTrue(edited.isEdited)
assertFalse(untouched.isEdited)
}
@Test
fun isEdited_wird_aus_dem_aktuellen_inhalt_abgeleitet_und_nicht_gespeichert() {
val untouched = block(item(checked = false))
assertFalse(untouched.isEdited)
val nowChecked = untouched.copy(items = untouched.items.map { it.copy(checked = true) })
assertTrue(nowChecked.isEdited)
val uncheckedAgain = nowChecked.copy(items = nowChecked.items.map { it.copy(checked = false) })
assertFalse(uncheckedAgain.isEdited)
}
}
@@ -0,0 +1,245 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* Blockstatus im PDF-Export: Nur bearbeitete Blöcke und nur angehakte Optionen gelangen ins PDF,
* alles wird aus den historischen Snapshots gebildet und stabil sortiert.
*/
class DocumentationToPdfExportModelMapperTest {
private val snapshot = PatientStammdatenSnapshot(
firstName = "Max",
lastName = "Mustermann",
birthDate = LocalDate.of(1980, 3, 14),
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
)
// Mittag in der Systemzeitzone -> das lokale Datum ist zeitzonenunabhängig der 23.08.2026.
private val createdAt: Instant =
LocalDate.of(2026, 8, 23).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant()
private fun item(
id: String,
order: Int,
checked: Boolean,
label: String = "Label $id",
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItem(
checkItemStateId = "state-$id",
definitionId = id,
label = label,
order = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
private fun block(id: String, order: Int, title: String = "Titel $id", items: List<CheckItem>) =
DocumentationBlock("state-$id", id, title, order, items)
private fun documentation(
blocks: List<DocumentationBlock>,
photos: List<Photo> = emptyList(),
signatures: List<Signature> = emptyList(),
) = Documentation(
id = "doc-1",
patientId = "patient-1",
patientSnapshot = snapshot,
status = DocumentationStatus.IN_PROGRESS,
createdAt = createdAt,
updatedAt = createdAt,
continuedFromDocumentationId = null,
catalogVersion = 4,
blocks = blocks,
photos = photos,
signatures = signatures,
)
// --- Blockfilter ---------------------------------------------------------------------
@Test
fun nur_bearbeitete_bloecke_werden_exportiert() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(item("a1", 1, checked = true))),
block("b", 2, items = listOf(item("b1", 1, checked = false))),
block("c", 3, items = listOf(item("c1", 1, checked = true))),
)
)
val model = DocumentationToPdfExportModelMapper.map(doc)
assertEquals(listOf("Titel a", "Titel c"), model.blocks.map { it.title })
}
@Test
fun block_mit_nur_hinweistexten_wird_nicht_exportiert() {
val doc = documentation(
listOf(
block(
"hint", 1,
items = listOf(
item("h1", 1, checked = true, isStaticHint = true),
item("h2", 2, checked = false),
),
),
)
)
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
@Test
fun dokumentation_ohne_bearbeiteten_block_ergibt_leere_blockliste() {
val doc = documentation(listOf(block("a", 1, items = listOf(item("a1", 1, checked = false)))))
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
// --- Itemfilter ----------------------------------------------------------------------
@Test
fun nur_angehakte_optionen_erscheinen_im_pdf() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("a1", 1, checked = true, label = "Ja"),
item("a2", 2, checked = false, label = "Nein"),
item("a3", 3, checked = true, label = "Auch ja"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Ja", "Auch ja"), items.map { it.label })
}
@Test
fun hinweistexte_eines_bearbeiteten_blocks_bleiben_im_pdf_erhalten() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("hint", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
item("a2", 2, checked = true, label = "Ringer-Lösung"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Reinigen mit:", "Ringer-Lösung"), items.map { it.label })
}
@Test
fun freitext_und_strukturierte_eingabe_werden_unveraendert_durchgereicht() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("f", 1, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "Rötung"),
item("s", 2, checked = true, label = "Maße", freeTextValue = "3|4", inputSpec = "SPEC"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), items[0])
assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1])
}
// --- Sortierung ----------------------------------------------------------------------
@Test
fun bloecke_und_optionen_werden_nach_order_sortiert() {
val doc = documentation(
listOf(
block("zweiter", 2, items = listOf(item("z1", 1, checked = true))),
block(
"erster", 1,
items = listOf(
item("e2", 2, checked = true, label = "B"),
item("e1", 1, checked = true, label = "A"),
),
),
)
)
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
assertEquals(listOf("Titel erster", "Titel zweiter"), blocks.map { it.title })
assertEquals(listOf("A", "B"), blocks.first().items.map { it.label })
}
@Test
fun fotos_und_signaturen_werden_nach_orderIndex_sortiert() {
val photos = listOf(
Photo("p2", "doc-1", "zweites.jpg", 1, createdAt),
Photo("p1", "doc-1", "erstes.jpg", 0, createdAt),
)
val signatures = listOf(
Signature("s2", "doc-1", "Berta", "b.png", 1, createdAt),
Signature("s1", "doc-1", "Anna", "a.png", 0, createdAt),
)
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), photos, signatures))
assertEquals(listOf("erstes.jpg", "zweites.jpg"), model.photos.map { it.fileName })
assertEquals(listOf("Anna", "Berta"), model.signatures.map { it.signerName })
assertEquals(listOf("a.png", "b.png"), model.signatures.map { it.fileName })
}
@Test
fun alle_signaturen_werden_exportiert_ohne_obergrenze() {
val signatures = (0 until 5).map { Signature("s$it", "doc-1", "Person $it", "s$it.png", it, createdAt) }
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), signatures = signatures))
assertEquals(5, model.signatures.size)
}
// --- Kopfdaten (Snapshot) ------------------------------------------------------------
@Test
fun kopfdaten_stammen_aus_dem_patienten_snapshot_der_dokumentation() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("doc-1", model.documentationId)
assertEquals("Max Mustermann", model.patientFullName)
assertEquals(LocalDate.of(1980, 3, 14), model.patientBirthDate)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
assertEquals(LocalDate.of(2026, 8, 23), model.createdAt)
}
@Test
fun dateiname_folgt_dem_schema_und_nutzt_snapshot_und_erstelldatum() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
}
@@ -0,0 +1,175 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
/**
* Reine JVM-Tests (keine Android-Abhängigkeit) für das Dateinamensschema
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`.
*/
class PdfFileNameGeneratorTest {
private val created = LocalDate.of(2026, 8, 23)
private val birth = LocalDate.of(1980, 3, 14)
private fun name(
first: String,
last: String,
createdAt: LocalDate = created,
birthDate: LocalDate = birth,
): String = PdfFileNameGenerator.generate(createdAt, first, last, birthDate)
// --- Grundschema ---------------------------------------------------------------------
@Test
fun standardfall_folgt_dem_vereinbarten_schema() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max", "Mustermann"))
}
@Test
fun datumsteile_werden_mit_fuehrenden_nullen_im_iso_format_ausgegeben() {
val result = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 1, 5), birthDate = LocalDate.of(1999, 2, 3))
assertEquals("2026-01-05_Max_Mustermann_1999-02-03.pdf", result)
}
@Test
fun schaltjahr_geburtsdatum_wird_korrekt_ausgegeben() {
val result = name("Max", "Mustermann", birthDate = LocalDate.of(2000, 2, 29))
assertEquals("2026-08-23_Max_Mustermann_2000-02-29.pdf", result)
}
@Test
fun ergebnis_ist_deterministisch() {
assertEquals(name("Max", "Mustermann"), name("Max", "Mustermann"))
}
@Test
fun verschiedene_erstelldaten_fuehren_zu_verschiedenen_dateinamen() {
val a = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 23))
val b = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 24))
assertNotEquals(a, b)
}
// --- Umlaute / Diakritika ------------------------------------------------------------
@Test
fun deutsche_umlaute_werden_transliteriert_statt_entfernt() {
assertEquals("2026-08-23_Max_Mueller_1980-03-14.pdf", name("Max", "Müller"))
assertEquals("2026-08-23_Aenne_Oezdemir_1980-03-14.pdf", name("Änne", "Özdemir"))
assertEquals("2026-08-23_Uwe_Ueberall_1980-03-14.pdf", name("Uwe", "Überall"))
}
@Test
fun eszett_wird_zu_doppel_s() {
assertEquals("2026-08-23_Max_Weiss_1980-03-14.pdf", name("Max", "Weiß"))
}
@Test
fun mueller_und_mller_bleiben_unterscheidbar() {
assertNotEquals(name("Max", "Müller"), name("Max", "Mller"))
}
@Test
fun andere_diakritika_werden_entfernt() {
assertEquals("2026-08-23_Jose_Celik_1980-03-14.pdf", name("José", "Çelik"))
}
// --- Leerzeichen / Sonderzeichen -----------------------------------------------------
@Test
fun leerzeichen_werden_zu_unterstrichen() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
}
@Test
fun mehrfaches_whitespace_wird_zu_einem_unterstrich_zusammengefasst() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna\t\nMaria", "Mustermann"))
}
@Test
fun mehrfache_unterstriche_werden_zusammengefasst() {
assertEquals("2026-08-23_Max_Muster_mann_1980-03-14.pdf", name("Max", "Muster__mann"))
}
@Test
fun fuehrende_und_nachgestellte_trennzeichen_werden_entfernt() {
val expected = "2026-08-23_Max_Mustermann_1980-03-14.pdf"
assertEquals(expected, name(" Max ", "Mustermann"))
assertEquals(expected, name("_Max_", "Mustermann"))
assertEquals(expected, name("-Max-", "Mustermann"))
}
@Test
fun bindestrich_im_doppelnamen_bleibt_erhalten() {
assertEquals("2026-08-23_Aenne-Marie_Meyer-Luedenscheidt_1980-03-14.pdf", name("Änne-Marie", "Meyer-Lüdenscheidt"))
}
@Test
fun apostroph_und_punkt_werden_entfernt() {
assertEquals("2026-08-23_Sean_OBrien_1980-03-14.pdf", name("Sean", "O'Brien"))
assertEquals("2026-08-23_Max_Dr_Mustermann_1980-03-14.pdf", name("Max", "Dr. Mustermann"))
}
// --- Sicherheit (Pfad-/Dateisystemzeichen) --------------------------------------------
@Test
fun pfadseparatoren_und_punkte_koennen_keinen_pfad_erzeugen() {
assertEquals(
"2026-08-23_Unbekannt_etcpasswd_1980-03-14.pdf",
name("../..", "../etc/passwd"),
)
}
@Test
fun fuer_dateinamen_ungueltige_zeichen_werden_entfernt() {
assertEquals(
"2026-08-23_MaxMuster_Mustermann_1980-03-14.pdf",
name("Max:Mus*ter?", "Mustermann"),
)
assertEquals(
"2026-08-23_Max_Mustermann_1980-03-14.pdf",
name("Ma<x>|\"", "Muster\\mann"),
)
}
@Test
fun emojis_werden_entfernt() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max😀", "Mustermann"))
}
@Test
fun dateiname_besteht_immer_nur_aus_sicheren_zeichen_und_endet_auf_pdf() {
val inputs = listOf(
"Max" to "Mustermann",
"Änne-Marie" to "Özdemir Weiß",
"José" to "O'Brien",
"../../x" to "a\\b/c",
" " to "",
"😀" to "???",
"Ma<x>|\"" to "Muster\\mann",
)
val allowed = Regex("^[A-Za-z0-9_.-]+$")
for ((first, last) in inputs) {
val result = name(first, last)
assertTrue("Unsicherer Dateiname: $result", allowed.matches(result))
assertTrue("Falsche Endung: $result", result.endsWith(".pdf"))
}
}
// --- Platzhalter für leere Namen -----------------------------------------------------
@Test
fun leerer_oder_blanker_name_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Mustermann_1980-03-14.pdf", name("", "Mustermann"))
assertEquals("2026-08-23_Max_Unbekannt_1980-03-14.pdf", name("Max", " "))
}
@Test
fun name_nur_aus_ungueltigen_zeichen_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Unbekannt_1980-03-14.pdf", name("???", "***"))
}
}