Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 829118233b | |||
| 58f1e6fb82 | |||
| 58430a0903 | |||
| 742ef39f05 | |||
| 489c4fa070 | |||
| c5fcf27367 | |||
| 09e7224c0a | |||
| 34cd20786b | |||
| e9403801d6 | |||
| 18a7af800f | |||
| bf9fcf7b0b | |||
| 4eaf1480a2 | |||
| 4a29d653bc | |||
| 77f71973a7 | |||
| 51370a9172 | |||
| 26cc81388e | |||
| dcfa11bef9 | |||
| 85600bfaed | |||
| e6bc99d144 | |||
| 2bc0d959e1 | |||
| 42e4b5c78c | |||
| b265f3fadb | |||
| e8ccab4f00 | |||
| b77f7f0f20 | |||
| 70bdc84cfa | |||
| 76bf0f16ec | |||
| 52a95e2b54 | |||
| cefecb86dd | |||
| 08f24aadd5 | |||
| 732c5b73c0 | |||
| f25596563c | |||
| b5ef793513 | |||
| 1fdcceb149 | |||
| 8a430d5089 | |||
| 0c7eaabbb7 | |||
| 63bc93ebd2 | |||
| 0f4ce2d2e1 | |||
| 08091a15a2 | |||
| 4a62d195ac | |||
| 075905c6e0 | |||
| 7948d2c3c6 | |||
| 2ddc1d960a | |||
| 32d23f5d52 | |||
| cda0b4d3fe | |||
| c52229b7f7 | |||
| 91e68c827a | |||
| 8c05a513fe | |||
| ebf85df188 | |||
| 81178cbbc2 |
@@ -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.3
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
@@ -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,73 +361,59 @@ 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
|
||||
|
||||
- [ ] Unit Tests
|
||||
- [ ] Room Tests
|
||||
- [x] Unit Tests
|
||||
- [x] Room Tests
|
||||
- [ ] UI Tests
|
||||
- [ ] Fehlerbehandlung
|
||||
- [ ] 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.
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
+378
@@ -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 })
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package de.medapp.wunddoku.data.repository
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.testutil.TestEnvironment
|
||||
import de.medapp.wunddoku.testutil.buildRepository
|
||||
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.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Datenschutz-Löschkonzept: Beim Löschen einer Dokumentation müssen ALLE personenbezogenen
|
||||
* Daten verschwinden - Datenbankzeilen, Fotos, Signaturen und abgeleitete Exporte (PDF/Bilder).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DocumentationDeletionPrivacyTest {
|
||||
|
||||
private lateinit var env: TestEnvironment
|
||||
|
||||
private class RecordingCleaner(private val failure: Throwable? = null) : ExportedFilesCleaner {
|
||||
val deleted = mutableListOf<String>()
|
||||
override suspend fun deleteExportsFor(documentationId: String) {
|
||||
deleted += documentationId
|
||||
failure?.let { throw it }
|
||||
}
|
||||
override suspend fun deleteStaleFiles() = Unit
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
env = TestEnvironment()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
env.close()
|
||||
}
|
||||
|
||||
private suspend fun createDocumentationWithData(repo: DocumentationRepository): String {
|
||||
env.insertPatient()
|
||||
val id = repo.createEmpty("patient-1").id
|
||||
repo.addPhoto(id, Uri.parse("content://test/1"))
|
||||
repo.saveSignature(id, "Pfleger Paul", signatureBitmap())
|
||||
return id
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loeschen_entfernt_auch_die_exportierten_dateien() = runDbTest {
|
||||
val cleaner = RecordingCleaner()
|
||||
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
|
||||
val id = createDocumentationWithData(repo)
|
||||
|
||||
repo.delete(id)
|
||||
|
||||
assertEquals(listOf(id), cleaner.deleted)
|
||||
assertNull(repo.getFullDocumentation(id))
|
||||
assertTrue(env.photoStorage.deletedAll.contains(id))
|
||||
assertTrue(env.signatureStorage.deletedAll.contains(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehler_beim_loeschen_der_exporte_verhindert_das_loeschen_der_dokumentation_nicht() = runDbTest {
|
||||
val repo = env.buildRepository(exportedFilesCleaner = RecordingCleaner(IllegalStateException("Test")))
|
||||
val id = createDocumentationWithData(repo)
|
||||
|
||||
repo.delete(id)
|
||||
|
||||
assertNull(repo.getFullDocumentation(id))
|
||||
assertTrue(repo.observeAll().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loeschen_einer_nicht_vorhandenen_dokumentation_loescht_keine_exporte() = runDbTest {
|
||||
val cleaner = RecordingCleaner()
|
||||
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
|
||||
|
||||
repo.delete("gibt-es-nicht")
|
||||
|
||||
assertTrue(cleaner.deleted.isEmpty())
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package de.medapp.wunddoku.data.repository
|
||||
|
||||
import android.database.sqlite.SQLiteFullException
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import de.medapp.wunddoku.pdf.PdfExportError
|
||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||
import de.medapp.wunddoku.testutil.FailingPhotoDao
|
||||
import de.medapp.wunddoku.testutil.FailingPhotoStorage
|
||||
import de.medapp.wunddoku.testutil.FailingSignatureDao
|
||||
import de.medapp.wunddoku.testutil.FailingSignatureStorage
|
||||
import de.medapp.wunddoku.testutil.TestEnvironment
|
||||
import de.medapp.wunddoku.testutil.ThrowingPdfExporter
|
||||
import de.medapp.wunddoku.testutil.ThrowingPdfToImagesConverter
|
||||
import de.medapp.wunddoku.testutil.buildRepository
|
||||
import de.medapp.wunddoku.testutil.expectThrowsSuspend
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import de.medapp.wunddoku.testutil.signatureBitmap
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
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.io.IOException
|
||||
|
||||
/**
|
||||
* Fehler- und Datenverlust-Tests für [DocumentationRepository] gegen eine echte In-Memory-
|
||||
* Room-Datenbank: Speichermangel (Datenbank/Dateisystem voll, Arbeitsspeicher), halbe
|
||||
* Ergebnisse, verwaiste Dateien und das "wirft-nie"-Verhalten des PDF-Exports.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DocumentationRepositoryErrorHandlingTest {
|
||||
|
||||
private lateinit var env: TestEnvironment
|
||||
private val someUri: Uri = Uri.parse("content://test/bild/1")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
env = TestEnvironment()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
env.close()
|
||||
}
|
||||
|
||||
private suspend fun newDocumentationId(): String {
|
||||
env.insertPatient()
|
||||
return env.documentationRepository.createEmpty("patient-1").id
|
||||
}
|
||||
|
||||
// --- Foto-Import ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun addPhoto_bei_fehlgeschlagenem_dateiimport_legt_keinen_db_eintrag_an() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val storage = FailingPhotoStorage().apply { importFailure = { IOException("Bild defekt (Test)") } }
|
||||
val repo = env.buildRepository(photoStorage = storage)
|
||||
|
||||
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
|
||||
|
||||
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addPhoto_bei_speichermangel_im_arbeitsspeicher_legt_keinen_db_eintrag_an() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val storage = FailingPhotoStorage().apply { importFailure = { OutOfMemoryError("Test") } }
|
||||
val repo = env.buildRepository(photoStorage = storage)
|
||||
|
||||
expectThrowsSuspend<OutOfMemoryError> { repo.addPhoto(documentationId, someUri) }
|
||||
|
||||
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addPhoto_bei_voller_datenbank_entfernt_die_bereits_importierte_datei() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val storage = FailingPhotoStorage()
|
||||
val photoDao = FailingPhotoDao(env.db.photoDao()).apply {
|
||||
insertFailure = SQLiteFullException("database or disk is full")
|
||||
}
|
||||
val repo = env.buildRepository(photoStorage = storage, photoDao = photoDao)
|
||||
|
||||
expectThrowsSuspend<SQLiteFullException> { repo.addPhoto(documentationId, someUri) }
|
||||
|
||||
assertEquals(1, storage.delegate.imported.size)
|
||||
assertEquals("verwaiste Datei muss entfernt werden", 1, storage.delegate.deleted.size)
|
||||
assertEquals(documentationId, storage.delegate.deleted.single().first)
|
||||
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addPhoto_funktioniert_nach_einem_fehlgeschlagenen_versuch_wieder() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val storage = FailingPhotoStorage().apply { importFailure = { call -> if (call == 1) IOException("einmalig") else null } }
|
||||
val repo = env.buildRepository(photoStorage = storage)
|
||||
|
||||
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
|
||||
repo.addPhoto(documentationId, someUri)
|
||||
|
||||
assertEquals(1, env.db.photoDao().getForDocumentation(documentationId).size)
|
||||
}
|
||||
|
||||
// --- Signaturen ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun saveSignature_bei_dateifehler_legt_weder_db_eintrag_noch_registereintrag_an() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val storage = FailingSignatureStorage().apply { saveFailure = IOException("Speicher voll (Test)") }
|
||||
val repo = env.buildRepository(signatureStorage = storage)
|
||||
|
||||
expectThrowsSuspend<IOException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
|
||||
|
||||
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
|
||||
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveSignature_bei_voller_datenbank_entfernt_die_gespeicherte_datei() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val storage = FailingSignatureStorage()
|
||||
val signatureDao = FailingSignatureDao(env.db.signatureDao()).apply {
|
||||
insertFailure = SQLiteFullException("database or disk is full")
|
||||
}
|
||||
val repo = env.buildRepository(signatureStorage = storage, signatureDao = signatureDao)
|
||||
|
||||
expectThrowsSuspend<SQLiteFullException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
|
||||
|
||||
assertEquals(1, storage.delegate.saved.size)
|
||||
assertEquals("verwaiste PNG-Datei muss entfernt werden", 1, storage.delegate.deleted.size)
|
||||
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveSignature_bleibt_erfolgreich_wenn_nur_das_wundversorger_register_scheitert() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val brokenRegisterStorage = FailingSignatureStorage().apply { saveFailure = IOException("Register-Speicher voll") }
|
||||
val brokenRegister = WoundCarerRepository(env.db.woundCarerDao(), brokenRegisterStorage)
|
||||
val repo = env.buildRepository(woundCarerRepository = brokenRegister)
|
||||
|
||||
val signature = repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap())
|
||||
|
||||
assertEquals("Pfleger Paul", signature.signerName)
|
||||
assertEquals(
|
||||
"Signatur der Dokumentation muss gespeichert sein",
|
||||
1,
|
||||
env.db.signatureDao().getForDocumentation(documentationId).size,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Fortsetzen ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fortsetzen_raeumt_bereits_kopierte_fotos_der_gescheiterten_kopie_auf() = runDbTest {
|
||||
val sourceId = newDocumentationId()
|
||||
env.documentationRepository.addPhoto(sourceId, someUri)
|
||||
env.documentationRepository.addPhoto(sourceId, someUri)
|
||||
env.photoStorage.failOnDuplicateCall = 2
|
||||
|
||||
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
|
||||
|
||||
val attemptedTarget = env.photoStorage.duplicated.first().targetDocumentationId
|
||||
assertTrue(
|
||||
"Ordner der nie entstandenen Dokumentation muss gelöscht werden",
|
||||
attemptedTarget in env.photoStorage.deletedAll,
|
||||
)
|
||||
assertEquals("keine halbe Dokumentation", 1, env.documentationRepository.observeAll().first().size)
|
||||
assertTrue(env.db.photoDao().getForDocumentation(attemptedTarget).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fortsetzen_laesst_quelle_und_deren_fotos_bei_fehler_unberuehrt() = runDbTest {
|
||||
val sourceId = newDocumentationId()
|
||||
env.documentationRepository.addPhoto(sourceId, someUri)
|
||||
env.photoStorage.failOnDuplicateCall = 1
|
||||
|
||||
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
|
||||
|
||||
assertEquals(1, env.db.photoDao().getForDocumentation(sourceId).size)
|
||||
assertFalse(sourceId in env.photoStorage.deletedAll)
|
||||
}
|
||||
|
||||
// --- PDF-Export wirft nie ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun exportPdf_uebersetzt_out_of_memory_in_ein_fehlerergebnis() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(OutOfMemoryError("Test")))
|
||||
|
||||
val result = repo.exportPdf(documentationId)
|
||||
|
||||
assertEquals(PdfExportResult.Failure(PdfExportError.OutOfMemory), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportPdf_uebersetzt_speicher_voll_in_ein_fehlerergebnis() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val repo = env.buildRepository(
|
||||
pdfExporter = ThrowingPdfExporter(IOException("write failed: ENOSPC (No space left on device)")),
|
||||
)
|
||||
|
||||
val result = repo.exportPdf(documentationId)
|
||||
|
||||
assertEquals(PdfExportResult.Failure(PdfExportError.InsufficientStorage), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportPdf_gibt_bei_unerwarteten_fehlern_keine_technischen_details_weiter() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val repo = env.buildRepository(
|
||||
pdfExporter = ThrowingPdfExporter(RuntimeException("/files/pdf/2026_Max_Mustermann.pdf kaputt")),
|
||||
)
|
||||
|
||||
val result = repo.exportPdf(documentationId)
|
||||
|
||||
assertTrue(result is PdfExportResult.Failure)
|
||||
val error = (result as PdfExportResult.Failure).error
|
||||
assertTrue(error is PdfExportError.Unexpected)
|
||||
assertFalse((error as PdfExportError.Unexpected).message.contains("Mustermann"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportPdf_reicht_coroutine_abbruch_weiter() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(CancellationException("abgebrochen")))
|
||||
|
||||
expectThrowsSuspend<CancellationException> { repo.exportPdf(documentationId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportPdf_bei_geschlossener_datenbank_liefert_fehler_statt_exception() = runDbTest {
|
||||
val documentationId = newDocumentationId()
|
||||
env.db.close()
|
||||
|
||||
val result = env.documentationRepository.exportPdf(documentationId)
|
||||
|
||||
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convertPdfToImages_uebersetzt_exceptions_in_fehlerergebnisse() = runDbTest {
|
||||
val file = File("/fake/x.pdf")
|
||||
|
||||
val oom = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(OutOfMemoryError()))
|
||||
.convertPdfToImages("doc", file)
|
||||
val full = env.buildRepository(
|
||||
pdfToImagesConverter = ThrowingPdfToImagesConverter(IOException("No space left on device")),
|
||||
).convertPdfToImages("doc", file)
|
||||
val other = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(IllegalStateException("x")))
|
||||
.convertPdfToImages("doc", file)
|
||||
|
||||
assertEquals(PdfToImagesResult.Failure.OutOfMemory, oom)
|
||||
assertEquals(PdfToImagesResult.Failure.InsufficientStorage, full)
|
||||
assertTrue(other is PdfToImagesResult.Failure.Unexpected)
|
||||
}
|
||||
|
||||
// --- Sonstiges -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun setChecked_mit_unbekannter_id_wirft_nicht() = runDbTest {
|
||||
newDocumentationId()
|
||||
|
||||
env.documentationRepository.setChecked("gibt-es-nicht", true)
|
||||
env.documentationRepository.updateFreeText("gibt-es-nicht", "Text")
|
||||
}
|
||||
}
|
||||
+545
@@ -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 })
|
||||
}
|
||||
}
|
||||
+156
@@ -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,222 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.testutil.expectThrowsSuspend
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
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.util.UUID
|
||||
|
||||
/**
|
||||
* Fehlerbehandlung von [InternalPhotoStorage] mit ECHTEN Dateien im App-Speicher: defekte,
|
||||
* leere oder nicht vorhandene Bilder, große Bilder (Speichermangel) sowie "kein halbes
|
||||
* Ergebnis zurücklassen".
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PhotoStorageErrorHandlingTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private lateinit var storage: InternalPhotoStorage
|
||||
private lateinit var documentationId: String
|
||||
private lateinit var workDir: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
storage = InternalPhotoStorage(context)
|
||||
// Eigene, zufällige Dokumentations-ID je Test: keine Berührung echter App-Daten.
|
||||
documentationId = "test-${UUID.randomUUID()}"
|
||||
workDir = File(context.cacheDir, "errtest-${UUID.randomUUID()}").apply { mkdirs() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
File(File(context.filesDir, "photos"), documentationId).deleteRecursively()
|
||||
workDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun docDir() = File(File(context.filesDir, "photos"), documentationId)
|
||||
|
||||
private fun filesInDocDir(): List<File> = docDir().listFiles()?.toList().orEmpty()
|
||||
|
||||
private fun writeJpeg(name: String, width: Int, height: Int): File {
|
||||
val file = File(workDir, name)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) }
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
bitmap.recycle()
|
||||
return file
|
||||
}
|
||||
|
||||
private fun uriOf(file: File): Uri = Uri.fromFile(file)
|
||||
|
||||
// --- Bildfehler beim Import ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun import_einer_textdatei_statt_bild_wirft_und_hinterlaesst_keine_datei() = runDbTest {
|
||||
val notAnImage = File(workDir, "kein_bild.jpg").apply { writeText("Das ist kein Bild.") }
|
||||
|
||||
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(notAnImage)) }
|
||||
|
||||
assertTrue(filesInDocDir().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun import_einer_leeren_datei_wirft_und_hinterlaesst_keine_datei() = runDbTest {
|
||||
val empty = File(workDir, "leer.jpg").apply { createNewFile() }
|
||||
|
||||
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(empty)) }
|
||||
|
||||
assertTrue(filesInDocDir().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun import_einer_abgeschnittenen_zufallsdatei_wirft_statt_abzustuerzen() = runDbTest {
|
||||
val garbage = File(workDir, "muell.jpg").apply { writeBytes(ByteArray(2048) { (it * 31).toByte() }) }
|
||||
|
||||
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(garbage)) }
|
||||
|
||||
assertTrue(filesInDocDir().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun import_einer_nicht_mehr_vorhandenen_datei_wirft() = runDbTest {
|
||||
val missing = File(workDir, "gibt_es_nicht.jpg")
|
||||
|
||||
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(missing)) }
|
||||
|
||||
assertTrue(filesInDocDir().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun import_mit_ungueltiger_content_uri_wirft() = runDbTest {
|
||||
val invalid = Uri.parse("content://de.medapp.gibt.es.nicht/bild/1")
|
||||
|
||||
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, invalid) }
|
||||
|
||||
assertTrue(filesInDocDir().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nach_einem_fehlgeschlagenen_import_funktioniert_der_naechste_import_normal() = runDbTest {
|
||||
val broken = File(workDir, "kaputt.jpg").apply { writeText("x") }
|
||||
val good = writeJpeg("gut.jpg", 200, 100)
|
||||
|
||||
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(broken)) }
|
||||
val fileName = storage.importFrom(documentationId, uriOf(good))
|
||||
|
||||
assertTrue(storage.resolve(documentationId, fileName).exists())
|
||||
assertEquals(1, filesInDocDir().size)
|
||||
}
|
||||
|
||||
// --- Speichermangel: große Bilder ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun sehr_grosses_bild_wird_speicherschonend_heruntergerechnet_importiert() = runDbTest {
|
||||
// 8000 x 300: viele Megapixel entlang der langen Kante, aber als Testbild selbst günstig.
|
||||
val big = writeJpeg("gross.jpg", 8000, 300)
|
||||
|
||||
val fileName = storage.importFrom(documentationId, uriOf(big))
|
||||
|
||||
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
android.graphics.BitmapFactory.decodeFile(storage.resolve(documentationId, fileName).absolutePath, bounds)
|
||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
assertTrue("gespeichert wurde ${bounds.outWidth}x${bounds.outHeight}", longest in 1 until 2 * ImageDownsampler.MAX_STORED_DIMENSION_PX)
|
||||
}
|
||||
|
||||
// --- Datei fehlt/defekt nach dem Import (Vorschau, PDF) ------------------------------
|
||||
|
||||
@Test
|
||||
fun thumbnail_einer_fehlenden_datei_liefert_null() = runDbTest {
|
||||
assertNull(storage.decodeThumbnail(documentationId, "fehlt.jpg", 300))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thumbnail_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
|
||||
val target = storage.resolve(documentationId, "defekt.jpg")
|
||||
target.parentFile!!.mkdirs()
|
||||
target.writeBytes(ByteArray(512) { 7 })
|
||||
|
||||
assertNull(storage.decodeThumbnail(documentationId, "defekt.jpg", 300))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeFitted_einer_fehlenden_oder_beschaedigten_datei_liefert_null() = runDbTest {
|
||||
assertNull(storage.decodeFitted(documentationId, "fehlt.jpg", 500, 800))
|
||||
|
||||
val target = storage.resolve(documentationId, "defekt.jpg")
|
||||
target.parentFile!!.mkdirs()
|
||||
target.writeBytes(ByteArray(512) { 7 })
|
||||
assertNull(storage.decodeFitted(documentationId, "defekt.jpg", 500, 800))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeFitted_mit_null_zielgroesse_stuerzt_nicht_ab() = runDbTest {
|
||||
val good = writeJpeg("gut.jpg", 200, 100)
|
||||
val fileName = storage.importFrom(documentationId, uriOf(good))
|
||||
|
||||
// Ungültige Zielgröße (0) darf höchstens null liefern, aber keine Exception werfen.
|
||||
storage.decodeFitted(documentationId, fileName, 0, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeFitted_vergroessert_nie_ueber_die_native_aufloesung() = runDbTest {
|
||||
val small = writeJpeg("klein.jpg", 100, 50)
|
||||
val fileName = storage.importFrom(documentationId, uriOf(small))
|
||||
|
||||
val bitmap = storage.decodeFitted(documentationId, fileName, 4000, 4000)
|
||||
|
||||
assertNotNull(bitmap)
|
||||
assertTrue(bitmap!!.width <= 100 && bitmap.height <= 50)
|
||||
bitmap.recycle()
|
||||
}
|
||||
|
||||
// --- Kopieren (Dokumentation fortsetzen) ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun duplicate_einer_fehlenden_quelldatei_wirft_und_hinterlaesst_keine_zieldatei() = runDbTest {
|
||||
val targetId = "test-${UUID.randomUUID()}"
|
||||
try {
|
||||
expectThrowsSuspend<Exception> { storage.duplicate(documentationId, "fehlt.jpg", targetId) }
|
||||
|
||||
val targetDir = File(File(context.filesDir, "photos"), targetId)
|
||||
assertTrue(targetDir.listFiles().orEmpty().isEmpty())
|
||||
} finally {
|
||||
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicate_erzeugt_eine_eigenstaendige_kopie() = runDbTest {
|
||||
val good = writeJpeg("gut.jpg", 200, 100)
|
||||
val fileName = storage.importFrom(documentationId, uriOf(good))
|
||||
val targetId = "test-${UUID.randomUUID()}"
|
||||
try {
|
||||
val copy = storage.duplicate(documentationId, fileName, targetId)
|
||||
|
||||
storage.delete(documentationId, fileName)
|
||||
assertTrue("Kopie muss das Löschen des Originals überleben", storage.resolve(targetId, copy).exists())
|
||||
} finally {
|
||||
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Löschen -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun loeschen_nicht_vorhandener_dateien_und_ordner_wirft_nicht() = runDbTest {
|
||||
storage.delete(documentationId, "gibt_es_nicht.jpg")
|
||||
storage.deleteAll(documentationId)
|
||||
assertFalse(docDir().exists())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.signature.SignatureStorage
|
||||
import de.medapp.wunddoku.testutil.FakePhotoStorage
|
||||
import de.medapp.wunddoku.testutil.FakeSignatureStorage
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
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.util.UUID
|
||||
|
||||
/**
|
||||
* Fehlerbehandlung des echten [AndroidPdfDocumentExporter] (echte Dateien im App-Speicher):
|
||||
* Schreibfehler, kein Datenverlust bereits vorhandener PDFs, keine halbfertigen Dateien sowie
|
||||
* graceful degradation bei fehlenden/defekten Fotos und Signaturen.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PdfExportErrorHandlingTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val documentationId = "test-${UUID.randomUUID()}"
|
||||
private val fileName = "2026-08-19_Max_Mustermann_1980-03-14.pdf"
|
||||
|
||||
private val pdfRoot get() = File(context.filesDir, "pdf")
|
||||
private val targetDir get() = File(pdfRoot, documentationId)
|
||||
private val targetFile get() = File(targetDir, fileName)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
pdfRoot.mkdirs()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
targetDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun exporter(
|
||||
photoStorage: PhotoStorage = FakePhotoStorage(),
|
||||
signatureStorage: SignatureStorage = FakeSignatureStorage(),
|
||||
) = AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
|
||||
|
||||
private fun model(photoCount: Int = 0, signatureCount: Int = 0) = PdfExportModel(
|
||||
documentationId = documentationId,
|
||||
fileName = fileName,
|
||||
patientFullName = "Max Mustermann",
|
||||
patientBirthDate = LocalDate.of(1980, 3, 14),
|
||||
patientDisplayAddress = "Musterstraße 1, 12345 Musterstadt",
|
||||
createdAt = LocalDate.of(2026, 8, 19),
|
||||
blocks = listOf(
|
||||
PdfExportBlock(
|
||||
title = "Wundgrund",
|
||||
items = listOf(PdfExportItem(label = "Granulation", isFreeText = false, freeTextValue = "")),
|
||||
),
|
||||
),
|
||||
signatures = (1..signatureCount).map { PdfExportSignature("Pfleger $it", "sig_$it.png") },
|
||||
photos = (1..photoCount).map { PdfExportPhoto("foto_$it.jpg") },
|
||||
)
|
||||
|
||||
private fun pageCount(file: File): Int =
|
||||
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
|
||||
PdfRenderer(fd).use { it.pageCount }
|
||||
}
|
||||
|
||||
private fun tmpFiles(): List<File> = targetDir.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }
|
||||
|
||||
// --- Normalfall als Referenz ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun erfolgreicher_export_erzeugt_ein_lesbares_pdf_ohne_temporaere_datei() = runDbTest {
|
||||
val result = exporter().export(model())
|
||||
|
||||
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
|
||||
assertTrue(targetFile.exists())
|
||||
assertTrue(pageCount(targetFile) >= 1)
|
||||
assertTrue(tmpFiles().isEmpty())
|
||||
}
|
||||
|
||||
// --- Schreibfehler -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun zielordner_ist_eine_datei_liefert_fehler_statt_absturz() = runDbTest {
|
||||
// Unter dem PDF-Wurzelordner liegt statt des Dokumentationsordners eine normale Datei.
|
||||
targetDir.deleteRecursively()
|
||||
targetDir.writeText("blockiert")
|
||||
|
||||
val result = exporter().export(model())
|
||||
|
||||
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
|
||||
assertTrue((result as PdfExportResult.Failure).error is PdfExportError.WriteFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehler_beim_schreiben_laesst_ein_vorhandenes_pdf_unangetastet() = runDbTest {
|
||||
val first = exporter().export(model())
|
||||
assertTrue(first is PdfExportResult.Success)
|
||||
val sizeBefore = targetFile.length()
|
||||
assertTrue(sizeBefore > 0)
|
||||
|
||||
// Zweiter Export scheitert: am Pfad der temporären Datei steht ein Ordner.
|
||||
File(targetDir, "$fileName.tmp").mkdirs()
|
||||
val second = exporter().export(model(signatureCount = 1))
|
||||
|
||||
assertTrue("erwartet Failure, war $second", second is PdfExportResult.Failure)
|
||||
assertTrue("bisheriges PDF darf nicht verloren gehen", targetFile.exists())
|
||||
assertEquals(sizeBefore, targetFile.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scheitert_das_umbenennen_bleibt_keine_halbfertige_tmp_datei_zurueck() = runDbTest {
|
||||
// Am endgültigen Pfad steht ein nicht-leerer Ordner: Löschen/Umbenennen schlagen fehl.
|
||||
targetFile.mkdirs()
|
||||
File(targetFile, "inhalt.txt").writeText("x")
|
||||
|
||||
val result = exporter().export(model())
|
||||
|
||||
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
|
||||
assertTrue(tmpFiles().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun erneuter_export_nach_einem_fehler_funktioniert() = runDbTest {
|
||||
File(targetDir, "$fileName.tmp").mkdirs()
|
||||
assertTrue(exporter().export(model()) is PdfExportResult.Failure)
|
||||
|
||||
File(targetDir, "$fileName.tmp").deleteRecursively()
|
||||
val retry = exporter().export(model())
|
||||
|
||||
assertTrue("erwartet Success, war $retry", retry is PdfExportResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wiederholter_export_ersetzt_die_vorhandene_datei_statt_weitere_anzulegen() = runDbTest {
|
||||
exporter().export(model())
|
||||
exporter().export(model(signatureCount = 1))
|
||||
|
||||
assertEquals(listOf(fileName), targetDir.listFiles().orEmpty().map { it.name })
|
||||
}
|
||||
|
||||
// --- Fotos / Signaturen fehlen oder sind defekt --------------------------------------
|
||||
|
||||
@Test
|
||||
fun fehlende_fotodateien_fuehren_zu_hinweisseiten_statt_zum_abbruch() = runDbTest {
|
||||
// FakePhotoStorage.decodeFitted liefert null (= Datei fehlt/defekt).
|
||||
val result = exporter().export(model(photoCount = 3))
|
||||
|
||||
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
|
||||
// Kopfseite + je eine Seite pro (nicht ladbarem) Foto
|
||||
assertTrue("mindestens Kopfseite + 3 Fotoseiten", pageCount(targetFile) >= 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foto_dekodierung_mit_out_of_memory_fuehrt_nicht_zum_absturz() = runDbTest {
|
||||
val oomStorage = object : PhotoStorage by FakePhotoStorage() {
|
||||
override suspend fun decodeFitted(
|
||||
documentationId: String,
|
||||
fileName: String,
|
||||
maxWidthPx: Int,
|
||||
maxHeightPx: Int,
|
||||
): Bitmap? = throw OutOfMemoryError("Test: zu wenig Speicher")
|
||||
}
|
||||
|
||||
val result = exporter(photoStorage = oomStorage).export(model(photoCount = 2))
|
||||
|
||||
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
|
||||
assertTrue("mindestens Kopfseite + 2 Fotoseiten", pageCount(targetFile) >= 3)
|
||||
assertTrue(tmpFiles().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foto_dekodierung_mit_io_fehler_fuehrt_nicht_zum_abbruch() = runDbTest {
|
||||
val failing = object : PhotoStorage by FakePhotoStorage() {
|
||||
override suspend fun decodeFitted(
|
||||
documentationId: String,
|
||||
fileName: String,
|
||||
maxWidthPx: Int,
|
||||
maxHeightPx: Int,
|
||||
): Bitmap? = throw java.io.IOException("Test: Lesefehler")
|
||||
}
|
||||
|
||||
val result = exporter(photoStorage = failing).export(model(photoCount = 1))
|
||||
|
||||
assertTrue(result is PdfExportResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehlende_signaturdatei_fuehrt_zu_platzhalter_statt_zum_abbruch() = runDbTest {
|
||||
// FakeSignatureStorage.decode liefert standardmäßig null.
|
||||
val result = exporter().export(model(signatureCount = 2))
|
||||
|
||||
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
|
||||
assertTrue(pageCount(targetFile) >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatur_dekodierung_mit_exception_fuehrt_nicht_zum_abbruch() = runDbTest {
|
||||
val failing = object : SignatureStorage by FakeSignatureStorage() {
|
||||
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
|
||||
throw IllegalStateException("Test: defekte Signatur")
|
||||
}
|
||||
|
||||
val result = exporter(signatureStorage = failing).export(model(signatureCount = 1))
|
||||
|
||||
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grosser_export_mit_vielen_nicht_ladbaren_fotos_bleibt_stabil() = runDbTest {
|
||||
val result = exporter().export(model(photoCount = 40, signatureCount = 3))
|
||||
|
||||
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
|
||||
assertFalse(tmpFiles().isNotEmpty())
|
||||
assertTrue("mindestens Kopfseite + 40 Fotoseiten", pageCount(targetFile) >= 41)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Fehlerpfade von [AndroidPdfOpener], [AndroidPdfSharer] und [AndroidPdfImageSharer]:
|
||||
* fehlende Datei, Datei außerhalb der FileProvider-Freigabe, keine passende App, verweigerter
|
||||
* Zugriff. Der Activity-Start wird über einen [ContextWrapper] abgefangen - es öffnet sich
|
||||
* dabei nie eine echte App.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PdfIntentErrorHandlingTest {
|
||||
|
||||
private val baseContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val id = "test-${UUID.randomUUID()}"
|
||||
private lateinit var sharedPdf: File
|
||||
private lateinit var sharedImage: File
|
||||
private lateinit var unsharedFile: File
|
||||
|
||||
/** Fängt startActivity ab und wirft optional einen vorgegebenen Fehler. */
|
||||
private class InterceptingContext(base: Context, private val failure: Throwable?) : ContextWrapper(base) {
|
||||
val started = mutableListOf<Intent>()
|
||||
|
||||
override fun startActivity(intent: Intent) {
|
||||
started += intent
|
||||
failure?.let { throw it }
|
||||
}
|
||||
|
||||
override fun startActivity(intent: Intent, options: Bundle?) {
|
||||
started += intent
|
||||
failure?.let { throw it }
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// Liegt in einem per file_paths.xml freigegebenen Ordner ("pdf/" bzw. "pdf_images/").
|
||||
sharedPdf = File(File(File(baseContext.filesDir, "pdf"), id), "test.pdf").apply {
|
||||
parentFile!!.mkdirs()
|
||||
writeBytes(byteArrayOf(1, 2, 3))
|
||||
}
|
||||
sharedImage = File(File(File(baseContext.filesDir, "pdf_images"), id), "seite_01.jpg").apply {
|
||||
parentFile!!.mkdirs()
|
||||
writeBytes(byteArrayOf(1, 2, 3))
|
||||
}
|
||||
// Liegt bewusst AUSSERHALB aller freigegebenen Ordner.
|
||||
unsharedFile = File(baseContext.cacheDir, "nicht_freigegeben_$id.pdf").apply { writeBytes(byteArrayOf(1)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
File(File(baseContext.filesDir, "pdf"), id).deleteRecursively()
|
||||
File(File(baseContext.filesDir, "pdf_images"), id).deleteRecursively()
|
||||
unsharedFile.delete()
|
||||
}
|
||||
|
||||
// --- PdfOpener -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun opener_meldet_fehlende_datei() {
|
||||
val context = InterceptingContext(baseContext, null)
|
||||
|
||||
val result = AndroidPdfOpener(context).open(File(baseContext.filesDir, "pdf/$id/weg.pdf"))
|
||||
|
||||
assertEquals(PdfOpenResult.Failure.FileNotFound, result)
|
||||
assertTrue(context.started.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opener_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
|
||||
val context = InterceptingContext(baseContext, null)
|
||||
|
||||
val result = AndroidPdfOpener(context).open(unsharedFile)
|
||||
|
||||
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfOpenResult.Failure.UriResolutionFailed)
|
||||
assertTrue(context.started.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opener_meldet_fehlenden_pdf_reader() {
|
||||
val context = InterceptingContext(baseContext, ActivityNotFoundException("kein Reader"))
|
||||
|
||||
val result = AndroidPdfOpener(context).open(sharedPdf)
|
||||
|
||||
assertEquals(PdfOpenResult.Failure.NoViewerAvailable, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opener_meldet_verweigerten_zugriff_als_unerwarteten_fehler() {
|
||||
val context = InterceptingContext(baseContext, SecurityException("verweigert"))
|
||||
|
||||
val result = AndroidPdfOpener(context).open(sharedPdf)
|
||||
|
||||
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opener_faengt_beliebige_laufzeitfehler_beim_start_ab() {
|
||||
val context = InterceptingContext(baseContext, IllegalStateException("kaputt"))
|
||||
|
||||
val result = AndroidPdfOpener(context).open(sharedPdf)
|
||||
|
||||
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opener_gibt_nur_leserechte_frei() {
|
||||
val context = InterceptingContext(baseContext, null)
|
||||
|
||||
val result = AndroidPdfOpener(context).open(sharedPdf)
|
||||
|
||||
assertEquals(PdfOpenResult.Success, result)
|
||||
val intent = context.started.single()
|
||||
assertEquals(Intent.ACTION_VIEW, intent.action)
|
||||
assertEquals("application/pdf", intent.type)
|
||||
assertNotEquals(0, intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
assertEquals(0, intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
}
|
||||
|
||||
// --- PdfSharer -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun sharer_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
|
||||
val context = InterceptingContext(baseContext, null)
|
||||
|
||||
val result = AndroidPdfSharer(context).share(unsharedFile)
|
||||
|
||||
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfShareResult.Failure.UriResolutionFailed)
|
||||
assertTrue(context.started.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharer_meldet_fehlende_app_zum_versenden() {
|
||||
val context = InterceptingContext(baseContext, ActivityNotFoundException("keine App"))
|
||||
|
||||
assertEquals(PdfShareResult.Failure.NoAppAvailable, AndroidPdfSharer(context).share(sharedPdf))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharer_meldet_verweigerten_zugriff_und_sonstige_fehler() {
|
||||
assertTrue(
|
||||
AndroidPdfSharer(InterceptingContext(baseContext, SecurityException("x"))).share(sharedPdf)
|
||||
is PdfShareResult.Failure.Unexpected
|
||||
)
|
||||
assertTrue(
|
||||
AndroidPdfSharer(InterceptingContext(baseContext, RuntimeException("x"))).share(sharedPdf)
|
||||
is PdfShareResult.Failure.Unexpected
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharer_gibt_nur_leserechte_frei() {
|
||||
val context = InterceptingContext(baseContext, null)
|
||||
|
||||
assertEquals(PdfShareResult.Success, AndroidPdfSharer(context).share(sharedPdf))
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val inner = context.started.single().getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
|
||||
assertEquals(Intent.ACTION_SEND, inner.action)
|
||||
assertEquals("application/pdf", inner.type)
|
||||
assertEquals(0, inner.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
}
|
||||
|
||||
// --- PdfImageSharer ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun image_sharer_meldet_leere_liste() {
|
||||
val context = InterceptingContext(baseContext, null)
|
||||
|
||||
assertEquals(PdfImageShareResult.Failure.NoImages, AndroidPdfImageSharer(context).share(emptyList()))
|
||||
assertTrue(context.started.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun image_sharer_meldet_unfreigegebene_datei_auch_wenn_nur_eine_von_mehreren_betroffen_ist() {
|
||||
val context = InterceptingContext(baseContext, null)
|
||||
|
||||
val result = AndroidPdfImageSharer(context).share(listOf(sharedImage, unsharedFile))
|
||||
|
||||
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfImageShareResult.Failure.UriResolutionFailed)
|
||||
assertTrue(context.started.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun image_sharer_meldet_fehlende_app_und_verweigerten_zugriff() {
|
||||
assertEquals(
|
||||
PdfImageShareResult.Failure.NoAppAvailable,
|
||||
AndroidPdfImageSharer(InterceptingContext(baseContext, ActivityNotFoundException("x"))).share(listOf(sharedImage)),
|
||||
)
|
||||
assertTrue(
|
||||
AndroidPdfImageSharer(InterceptingContext(baseContext, SecurityException("x"))).share(listOf(sharedImage))
|
||||
is PdfImageShareResult.Failure.Unexpected
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
/** Fehlerbehandlung von [AndroidPdfToImagesConverter] mit echten PDF-Dateien. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PdfToImagesErrorHandlingTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val converter = AndroidPdfToImagesConverter(context)
|
||||
private val documentationId = "test-${UUID.randomUUID()}"
|
||||
private lateinit var workDir: File
|
||||
|
||||
private val imagesDir get() = File(File(context.filesDir, "pdf_images"), documentationId)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
workDir = File(context.cacheDir, "errtest-${UUID.randomUUID()}").apply { mkdirs() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
imagesDir.deleteRecursively()
|
||||
workDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun validPdf(pages: Int = 1): File {
|
||||
val file = File(workDir, "gueltig.pdf")
|
||||
val document = PdfDocument()
|
||||
repeat(pages) { index ->
|
||||
val page = document.startPage(PdfDocument.PageInfo.Builder(595, 842, index + 1).create())
|
||||
page.canvas.drawText("Seite ${index + 1}", 50f, 50f, Paint().apply { textSize = 20f })
|
||||
document.finishPage(page)
|
||||
}
|
||||
FileOutputStream(file).use { document.writeTo(it) }
|
||||
document.close()
|
||||
return file
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehlende_pdf_liefert_file_not_found() = runDbTest {
|
||||
val result = converter.convert(documentationId, File(workDir, "gibt_es_nicht.pdf"))
|
||||
|
||||
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun beschaedigte_pdf_liefert_fehler_statt_absturz_und_hinterlaesst_keine_bilder() = runDbTest {
|
||||
val corrupt = File(workDir, "defekt.pdf").apply { writeBytes(ByteArray(1024) { (it % 251).toByte() }) }
|
||||
|
||||
val result = converter.convert(documentationId, corrupt)
|
||||
|
||||
assertTrue("erwartet Failure, war $result", result is PdfToImagesResult.Failure)
|
||||
assertTrue(imagesDir.listFiles().orEmpty().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leere_pdf_datei_liefert_fehler_statt_absturz() = runDbTest {
|
||||
val empty = File(workDir, "leer.pdf").apply { createNewFile() }
|
||||
|
||||
val result = converter.convert(documentationId, empty)
|
||||
|
||||
assertTrue("erwartet Failure, war $result", result is PdfToImagesResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gueltige_pdf_wird_seitenweise_in_bilder_umgewandelt() = runDbTest {
|
||||
val result = converter.convert(documentationId, validPdf(pages = 2))
|
||||
|
||||
assertTrue("erwartet Success, war $result", result is PdfToImagesResult.Success)
|
||||
val files = (result as PdfToImagesResult.Success).imageFiles
|
||||
assertEquals(2, files.size)
|
||||
assertTrue(files.all { it.exists() && it.length() > 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun veraltete_bilder_eines_frueheren_exports_werden_entfernt() = runDbTest {
|
||||
imagesDir.mkdirs()
|
||||
val stale = File(imagesDir, "seite_09.jpg").apply { writeText("alt") }
|
||||
|
||||
val result = converter.convert(documentationId, validPdf(pages = 1))
|
||||
|
||||
assertTrue(result is PdfToImagesResult.Success)
|
||||
assertFalse("veraltete Seite darf nicht mitgeteilt werden", stale.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nach_einem_fehler_funktioniert_die_naechste_umwandlung() = runDbTest {
|
||||
val corrupt = File(workDir, "defekt.pdf").apply { writeBytes(ByteArray(64) { 1 }) }
|
||||
assertTrue(converter.convert(documentationId, corrupt) is PdfToImagesResult.Failure)
|
||||
|
||||
val result = converter.convert(documentationId, validPdf())
|
||||
|
||||
assertTrue(result is PdfToImagesResult.Success)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package de.medapp.wunddoku.privacy
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.view.WindowManager
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.MainActivity
|
||||
import de.medapp.wunddoku.R
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Datenschutz-Prüfung (QA-006) der ZUSAMMENGEFÜHRTEN, tatsächlich installierten App: Das fertige
|
||||
* Manifest enthält auch Einträge aus Bibliotheken, die im eigenen Manifest nicht sichtbar sind.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivacyConfigurationTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val packageName = context.packageName
|
||||
private val id = "test-${UUID.randomUUID()}"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
File(context.filesDir, "photos/$id").mkdirs()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
listOf("photos", "signatures", "pdf", "pdf_images").forEach {
|
||||
File(File(context.filesDir, it), id).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun packageInfo(flags: Int) = context.packageManager.getPackageInfo(packageName, flags)
|
||||
|
||||
// --- Berechtigungen ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun app_fordert_keine_gefaehrlichen_oder_unnoetigen_berechtigungen_an() {
|
||||
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
|
||||
|
||||
val forbidden = listOf(
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.ACCESS_NETWORK_STATE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.MANAGE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.READ_CONTACTS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
)
|
||||
assertEquals("Verbotene Berechtigungen: ${requested.filter { it in forbidden }}", emptyList<String>(), requested.filter { it in forbidden })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun app_hat_ausser_dem_androidx_interna_keine_weiteren_berechtigungen() {
|
||||
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
|
||||
|
||||
// Die Signatur-Berechtigung von androidx (Schutz dynamischer Receiver) ist unkritisch.
|
||||
val allowed = setOf("$packageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
|
||||
val unexpected = requested.filterNot { it in allowed }
|
||||
|
||||
assertTrue("Unerwartete Berechtigungen im zusammengeführten Manifest: $unexpected", unexpected.isEmpty())
|
||||
}
|
||||
|
||||
// --- Backup --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun backup_ist_deaktiviert() {
|
||||
val flags = context.applicationInfo.flags
|
||||
assertEquals("android:allowBackup muss false sein", 0, flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun data_extraction_rules_schliessen_alles_aus() {
|
||||
val parser = context.resources.getXml(R.xml.data_extraction_rules)
|
||||
var section: String? = null
|
||||
val excluded = mutableMapOf<String, MutableSet<String>>()
|
||||
var includeFound = false
|
||||
|
||||
while (parser.next() != XmlPullParser.END_DOCUMENT) {
|
||||
if (parser.eventType == XmlPullParser.START_TAG) {
|
||||
when (parser.name) {
|
||||
"cloud-backup", "device-transfer" -> {
|
||||
section = parser.name
|
||||
excluded[parser.name] = mutableSetOf()
|
||||
}
|
||||
"exclude" -> {
|
||||
val domain = parser.getAttributeValue(null, "domain")
|
||||
val current = section
|
||||
if (domain != null && current != null) excluded[current]?.add(domain)
|
||||
}
|
||||
"include" -> includeFound = true
|
||||
}
|
||||
} else if (parser.eventType == XmlPullParser.END_TAG && parser.name == section) {
|
||||
section = null
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse("Kein <include> erlaubt", includeFound)
|
||||
assertEquals(setOf("cloud-backup", "device-transfer"), excluded.keys)
|
||||
excluded.forEach { (name, domains) ->
|
||||
assertTrue("$name muss database, file und sharedpref ausschließen, war $domains", domains.containsAll(setOf("database", "file", "sharedpref")))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exportierte Komponenten ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun ausser_der_start_activity_sind_keine_eigenen_komponenten_exportiert() {
|
||||
val info = packageInfo(
|
||||
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
)
|
||||
|
||||
// Debug-Builds enthalten zusätzlich Test-/Tooling-Activities von androidx (nicht im Release).
|
||||
val toolingPrefixes = listOf("androidx.compose.ui.tooling", "androidx.activity.ComponentActivity", "androidx.compose.ui.test")
|
||||
val exportedActivities = info.activities.orEmpty().filter { it.exported }.map { it.name }
|
||||
.filterNot { name -> toolingPrefixes.any { name.startsWith(it) } }
|
||||
assertEquals(listOf(MainActivity::class.java.name), exportedActivities)
|
||||
|
||||
val exportedServices = info.services.orEmpty().filter { it.exported }.map { it.name }
|
||||
assertTrue("Exportierte Services: $exportedServices", exportedServices.isEmpty())
|
||||
|
||||
val exportedProviders = info.providers.orEmpty().filter { it.exported }.map { it.name }
|
||||
assertTrue("Exportierte ContentProvider: $exportedProviders", exportedProviders.isEmpty())
|
||||
|
||||
// androidx.profileinstaller ist per Systemberechtigung (DUMP) geschützt und unkritisch.
|
||||
val exportedReceivers = info.receivers.orEmpty().filter { it.exported }.map { it.name }
|
||||
.filterNot { it == "androidx.profileinstaller.ProfileInstallReceiver" }
|
||||
assertTrue("Exportierte Receiver: $exportedReceivers", exportedReceivers.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileprovider_ist_nicht_exportiert_und_vergibt_nur_gezielte_rechte() {
|
||||
val provider = context.packageManager.getProviderInfo(
|
||||
ComponentName(packageName, FileProvider::class.java.name),
|
||||
PackageManager.GET_META_DATA,
|
||||
)
|
||||
|
||||
assertFalse(provider.exported)
|
||||
assertTrue(provider.grantUriPermissions)
|
||||
assertEquals("$packageName.fileprovider", provider.authority)
|
||||
}
|
||||
|
||||
// --- FileProvider-Freigaben (minimale Freigabe) --------------------------------------
|
||||
|
||||
private fun uriFor(file: File) = FileProvider.getUriForFile(context, "$packageName.fileprovider", file)
|
||||
|
||||
@Test
|
||||
fun pdf_und_bildexporte_sind_freigegeben() {
|
||||
val pdf = File(context.filesDir, "pdf/$id/x.pdf").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
val image = File(context.filesDir, "pdf_images/$id/s.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
|
||||
assertTrue(uriFor(pdf).toString().startsWith("content://$packageName.fileprovider/"))
|
||||
assertTrue(uriFor(image).toString().startsWith("content://$packageName.fileprovider/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun originalfotos_und_signaturen_sind_nicht_ueber_den_fileprovider_freigabefaehig() {
|
||||
val photo = File(context.filesDir, "photos/$id/foto.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
val signature = File(context.filesDir, "signatures/$id/sig.png").apply { parentFile!!.mkdirs(); writeText("x") }
|
||||
|
||||
for (file in listOf(photo, signature)) {
|
||||
try {
|
||||
uriFor(file)
|
||||
fail("${file.parentFile!!.parentFile!!.name}/ darf nicht freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet: nicht in file_paths.xml konfiguriert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datenbank_und_sonstige_interne_dateien_sind_nicht_freigabefaehig() {
|
||||
val database = context.getDatabasePath("irgendeine.db")
|
||||
val other = File(context.filesDir, "geheim_$id.txt").apply { writeText("x") }
|
||||
|
||||
try {
|
||||
uriFor(other)
|
||||
fail("Dateien im Wurzelordner von filesDir dürfen nicht freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
} finally {
|
||||
other.delete()
|
||||
}
|
||||
try {
|
||||
uriFor(database)
|
||||
fail("Die Datenbank darf nie freigebbar sein")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Speicherort ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun dokumentationsdaten_liegen_im_privaten_internen_speicher_und_sind_nicht_fuer_andere_lesbar() {
|
||||
val file = File(context.filesDir, "photos/$id/foto.jpg").apply { writeText("x") }
|
||||
|
||||
assertTrue(file.canonicalPath.startsWith(context.filesDir.canonicalPath))
|
||||
val external = context.getExternalFilesDir(null)?.canonicalPath
|
||||
if (external != null) assertFalse(file.canonicalPath.startsWith(external))
|
||||
|
||||
val permissions = java.nio.file.Files.getPosixFilePermissions(file.toPath())
|
||||
assertFalse("Datei darf nicht 'others'-lesbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_READ))
|
||||
assertFalse("Datei darf nicht 'others'-schreibbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_WRITE))
|
||||
}
|
||||
|
||||
// --- Bildschirmschutz ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun hauptfenster_verwendet_flag_secure_gegen_screenshots_und_app_switcher_vorschau() {
|
||||
var flags = 0
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { activity -> flags = activity.window.attributes.flags }
|
||||
}
|
||||
|
||||
assertNotEquals("FLAG_SECURE muss gesetzt sein", 0, flags and WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package de.medapp.wunddoku.signature
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.medapp.wunddoku.testutil.blankBitmap
|
||||
import de.medapp.wunddoku.testutil.expectThrowsSuspend
|
||||
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.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.util.UUID
|
||||
|
||||
/**
|
||||
* Fehlerbehandlung von [InternalSignatureStorage] mit echten Dateien (eigenes Test-
|
||||
* Wurzelverzeichnis, berührt keine echten Signaturen).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SignatureStorageErrorHandlingTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val rootDirName = "test_signatures_${UUID.randomUUID()}"
|
||||
private lateinit var storage: InternalSignatureStorage
|
||||
private val documentationId = "doc-1"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
storage = InternalSignatureStorage(context, rootDirName)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
File(context.filesDir, rootDirName).deleteRecursively()
|
||||
}
|
||||
|
||||
private fun filesInDocDir(): List<File> =
|
||||
File(File(context.filesDir, rootDirName), documentationId).listFiles()?.toList().orEmpty()
|
||||
|
||||
@Test
|
||||
fun speichern_einer_recycelten_bitmap_wirft_und_hinterlaesst_keine_datei() = runDbTest {
|
||||
val recycled = signatureBitmap().also { it.recycle() }
|
||||
|
||||
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
|
||||
|
||||
assertTrue("keine leere PNG-Datei als scheinbar gültige Signatur", filesInDocDir().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nach_fehlgeschlagenem_speichern_funktioniert_das_naechste_speichern() = runDbTest {
|
||||
val recycled = signatureBitmap().also { it.recycle() }
|
||||
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
|
||||
|
||||
val fileName = storage.save(documentationId, signatureBitmap())
|
||||
|
||||
assertTrue(storage.resolve(documentationId, fileName).exists())
|
||||
assertEquals(1, filesInDocDir().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gespeicherte_signatur_laesst_sich_wieder_dekodieren() = runDbTest {
|
||||
val fileName = storage.save(documentationId, signatureBitmap())
|
||||
|
||||
val decoded = storage.decode(documentationId, fileName)
|
||||
|
||||
assertNotNull(decoded)
|
||||
assertEquals(40, decoded!!.width)
|
||||
decoded.recycle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decode_einer_fehlenden_datei_liefert_null() = runDbTest {
|
||||
assertNull(storage.decode(documentationId, "fehlt.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decode_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
|
||||
val target = storage.resolve(documentationId, "defekt.png")
|
||||
target.parentFile!!.mkdirs()
|
||||
target.writeBytes(ByteArray(256) { 3 })
|
||||
|
||||
assertNull(storage.decode(documentationId, "defekt.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decode_einer_leeren_datei_liefert_null() = runDbTest {
|
||||
val target = storage.resolve(documentationId, "leer.png")
|
||||
target.parentFile!!.mkdirs()
|
||||
target.createNewFile()
|
||||
|
||||
assertNull(storage.decode(documentationId, "leer.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loeschen_nicht_vorhandener_dateien_wirft_nicht() = runDbTest {
|
||||
storage.delete(documentationId, "gibt_es_nicht.png")
|
||||
storage.deleteAll(documentationId)
|
||||
assertFalse(File(File(context.filesDir, rootDirName), documentationId).exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leere_zeichnung_wird_von_der_validierung_erkannt() {
|
||||
val blank: Bitmap = blankBitmap()
|
||||
assertTrue(SignatureBitmapValidation.isBlank(blank))
|
||||
assertFalse(SignatureBitmapValidation.isBlank(signatureBitmap()))
|
||||
}
|
||||
}
|
||||
@@ -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,198 @@
|
||||
package de.medapp.wunddoku.testutil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
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.entity.PatientEntity
|
||||
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
|
||||
import de.medapp.wunddoku.data.dao.PhotoDao
|
||||
import de.medapp.wunddoku.data.dao.SignatureDao
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.data.repository.WoundCarerRepository
|
||||
import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||
import de.medapp.wunddoku.signature.SignatureStorage
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Hilfsklassen für die Fehlerbehandlungs-Tests. Die bestehenden Fakes (Fakes.kt) bleiben
|
||||
* unverändert; hier werden sie per Delegation um gezielt auslösbare Fehler ergänzt.
|
||||
*/
|
||||
|
||||
/** [PhotoStorage] mit konfigurierbaren Fehlern pro Aufruf (1-basierte Aufrufnummer). */
|
||||
class FailingPhotoStorage(
|
||||
val delegate: FakePhotoStorage = FakePhotoStorage(),
|
||||
) : PhotoStorage by delegate {
|
||||
|
||||
/** Liefert für die n-te [importFrom]-Anweisung einen Fehler (oder `null` = Erfolg). */
|
||||
var importFailure: (Int) -> Throwable? = { null }
|
||||
private var importCalls = 0
|
||||
|
||||
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
|
||||
importCalls++
|
||||
importFailure(importCalls)?.let { throw it }
|
||||
return delegate.importFrom(documentationId, sourceUri)
|
||||
}
|
||||
}
|
||||
|
||||
/** [SignatureStorage] mit gezielt auslösbarem Fehler beim Speichern. */
|
||||
class FailingSignatureStorage(
|
||||
val delegate: FakeSignatureStorage = FakeSignatureStorage(),
|
||||
) : SignatureStorage by delegate {
|
||||
|
||||
var saveFailure: Throwable? = null
|
||||
|
||||
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
|
||||
saveFailure?.let { throw it }
|
||||
return delegate.save(documentationId, bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
/** Exporter, der eine beliebige Exception/Error wirft (statt ein Failure-Ergebnis zu liefern). */
|
||||
class ThrowingPdfExporter(private val error: Throwable) : DocumentationPdfExporter {
|
||||
override suspend fun export(model: de.medapp.wunddoku.pdf.PdfExportModel): de.medapp.wunddoku.pdf.PdfExportResult =
|
||||
throw error
|
||||
}
|
||||
|
||||
/** Converter, der eine beliebige Exception/Error wirft. */
|
||||
class ThrowingPdfToImagesConverter(private val error: Throwable) : PdfToImagesConverter {
|
||||
override suspend fun convert(documentationId: String, pdfFile: File): de.medapp.wunddoku.pdf.PdfToImagesResult =
|
||||
throw error
|
||||
}
|
||||
|
||||
/** [CheckItemStateDao] mit abschaltbaren Schreibfehlern (z. B. "database or disk is full"). */
|
||||
class FailingCheckItemStateDao(
|
||||
private val delegate: CheckItemStateDao,
|
||||
) : CheckItemStateDao by delegate {
|
||||
|
||||
@Volatile var failure: Throwable? = null
|
||||
|
||||
override suspend fun setChecked(id: String, checked: Boolean) {
|
||||
failure?.let { throw it }
|
||||
delegate.setChecked(id, checked)
|
||||
}
|
||||
|
||||
override suspend fun updateFreeTextValue(id: String, value: String) {
|
||||
failure?.let { throw it }
|
||||
delegate.updateFreeTextValue(id, value)
|
||||
}
|
||||
}
|
||||
|
||||
/** [DocumentationDao] mit abschaltbarem Fehler beim Laden einer kompletten Dokumentation. */
|
||||
class FailingDocumentationDao(
|
||||
private val delegate: DocumentationDao,
|
||||
) : DocumentationDao by delegate {
|
||||
|
||||
@Volatile var loadFailure: Throwable? = null
|
||||
|
||||
override suspend fun getFullDocumentation(id: String): DocumentationWithDetails? {
|
||||
loadFailure?.let { throw it }
|
||||
return delegate.getFullDocumentation(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** [PatientDao] mit abschaltbarem Fehler beim Einfügen. */
|
||||
class FailingPatientDao(
|
||||
private val delegate: PatientDao,
|
||||
) : PatientDao by delegate {
|
||||
|
||||
@Volatile var insertFailure: Throwable? = null
|
||||
|
||||
override suspend fun insert(patient: PatientEntity) {
|
||||
insertFailure?.let { throw it }
|
||||
delegate.insert(patient)
|
||||
}
|
||||
}
|
||||
|
||||
/** [PhotoDao] mit abschaltbarem Fehler beim Einfügen. */
|
||||
class FailingPhotoDao(
|
||||
private val delegate: PhotoDao,
|
||||
) : PhotoDao by delegate {
|
||||
|
||||
@Volatile var insertFailure: Throwable? = null
|
||||
|
||||
override suspend fun insert(photo: de.medapp.wunddoku.data.entity.PhotoEntity) {
|
||||
insertFailure?.let { throw it }
|
||||
delegate.insert(photo)
|
||||
}
|
||||
}
|
||||
|
||||
/** [SignatureDao] mit abschaltbarem Fehler beim Einfügen. */
|
||||
class FailingSignatureDao(
|
||||
private val delegate: SignatureDao,
|
||||
) : SignatureDao by delegate {
|
||||
|
||||
@Volatile var insertFailure: Throwable? = null
|
||||
|
||||
override suspend fun insert(signature: de.medapp.wunddoku.data.entity.SignatureEntity) {
|
||||
insertFailure?.let { throw it }
|
||||
delegate.insert(signature)
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut ein [DocumentationRepository] auf der echten In-Memory-DB mit austauschbaren Bausteinen. */
|
||||
fun TestEnvironment.buildRepository(
|
||||
photoStorage: PhotoStorage = this.photoStorage,
|
||||
signatureStorage: SignatureStorage = this.signatureStorage,
|
||||
woundCarerRepository: WoundCarerRepository = this.woundCarerRepository,
|
||||
pdfExporter: DocumentationPdfExporter = this.pdfExporter,
|
||||
pdfToImagesConverter: PdfToImagesConverter = this.pdfToImagesConverter,
|
||||
checkItemStateDao: CheckItemStateDao = db.checkItemStateDao(),
|
||||
photoDao: PhotoDao = db.photoDao(),
|
||||
signatureDao: SignatureDao = db.signatureDao(),
|
||||
documentationDao: DocumentationDao = db.documentationDao(),
|
||||
exportedFilesCleaner: de.medapp.wunddoku.pdf.ExportedFilesCleaner = de.medapp.wunddoku.pdf.ExportedFilesCleaner.None,
|
||||
): DocumentationRepository = DocumentationRepository(
|
||||
documentationDao = documentationDao,
|
||||
blockStateDao = db.blockStateDao(),
|
||||
checkItemStateDao = checkItemStateDao,
|
||||
photoDao = photoDao,
|
||||
signatureDao = signatureDao,
|
||||
patientDao = db.patientDao(),
|
||||
photoStorage = photoStorage,
|
||||
signatureStorage = signatureStorage,
|
||||
woundCarerRepository = woundCarerRepository,
|
||||
pdfExporter = pdfExporter,
|
||||
pdfToImagesConverter = pdfToImagesConverter,
|
||||
db = db,
|
||||
exportedFilesCleaner = exportedFilesCleaner,
|
||||
)
|
||||
|
||||
/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */
|
||||
suspend inline fun <reified T : Throwable> expectThrowsSuspend(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")
|
||||
}
|
||||
|
||||
/** Wartet (max. [timeoutMs]) bis [condition] wahr ist - für asynchrone ViewModel-Zustände. */
|
||||
suspend fun awaitCondition(timeoutMs: Long = 5_000, condition: () -> Boolean) {
|
||||
withTimeout(timeoutMs) {
|
||||
while (!condition()) delay(20)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie [awaitCondition], aber mit Suspend-Bedingung (z. B. Abfrage der Datenbank). */
|
||||
suspend fun awaitUntil(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
|
||||
withTimeout(timeoutMs) {
|
||||
while (!condition()) delay(20)
|
||||
}
|
||||
}
|
||||
|
||||
/** Führt [block] auf dem Main-Thread aus (ViewModels greifen intern auf Main-State zu). */
|
||||
fun <T> onMain(block: () -> T): T {
|
||||
var result: Result<T>? = null
|
||||
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
result = runCatching(block)
|
||||
}
|
||||
return result!!.getOrThrow()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import android.database.sqlite.SQLiteFullException
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.domain.model.CheckItem
|
||||
import de.medapp.wunddoku.pdf.PdfExportError
|
||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||
import de.medapp.wunddoku.testutil.FailingCheckItemStateDao
|
||||
import de.medapp.wunddoku.testutil.FailingDocumentationDao
|
||||
import de.medapp.wunddoku.testutil.FailingPhotoStorage
|
||||
import de.medapp.wunddoku.testutil.FailingSignatureStorage
|
||||
import de.medapp.wunddoku.testutil.TestEnvironment
|
||||
import de.medapp.wunddoku.testutil.ThrowingPdfExporter
|
||||
import de.medapp.wunddoku.testutil.awaitCondition
|
||||
import de.medapp.wunddoku.testutil.awaitUntil
|
||||
import de.medapp.wunddoku.testutil.blankBitmap
|
||||
import de.medapp.wunddoku.testutil.buildRepository
|
||||
import de.medapp.wunddoku.testutil.firstPlainItem
|
||||
import de.medapp.wunddoku.testutil.onMain
|
||||
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.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.io.IOException
|
||||
|
||||
/**
|
||||
* Fehlerbehandlung im [DocumentationEditViewModel] mit echter In-Memory-Room-DB: Autosave bei
|
||||
* vollem Speicher (Datenverlust verhindern), Fotoimport (defekte Bilder, Speichermangel),
|
||||
* Signatur, PDF-Export und Teilen. Kein Fehler darf einen Absturz oder einen dauerhaft
|
||||
* hängenden Ladezustand verursachen.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DocumentationEditViewModelErrorHandlingTest {
|
||||
|
||||
private lateinit var env: TestEnvironment
|
||||
private lateinit var documentationId: String
|
||||
private val someUri: Uri = Uri.parse("content://test/bild/1")
|
||||
|
||||
@Before
|
||||
fun setUp() = runDbTest {
|
||||
env = TestEnvironment()
|
||||
env.insertPatient()
|
||||
documentationId = env.documentationRepository.createEmpty("patient-1").id
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
env.close()
|
||||
}
|
||||
|
||||
private suspend fun createViewModel(
|
||||
repository: DocumentationRepository = env.documentationRepository,
|
||||
id: String = documentationId,
|
||||
): DocumentationEditViewModel {
|
||||
val viewModel = onMain { DocumentationEditViewModel(id, repository, env.woundCarerRepository) }
|
||||
awaitCondition { !viewModel.uiState.value.isLoading }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private fun DocumentationEditViewModel.plainItem(): CheckItem =
|
||||
uiState.value.documentation!!.blocks.first().firstPlainItem()
|
||||
|
||||
private fun DocumentationEditViewModel.freeTextItem(): CheckItem =
|
||||
uiState.value.documentation!!.blocks.first().items.first { it.isFreeText }
|
||||
|
||||
private fun DocumentationEditViewModel.itemById(id: String): CheckItem =
|
||||
uiState.value.documentation!!.blocks.flatMap { it.items }.first { it.checkItemStateId == id }
|
||||
|
||||
private suspend fun storedFreeText(itemId: String): String? =
|
||||
env.documentationRepository.getFullDocumentation(documentationId)
|
||||
?.blocks?.flatMap { it.items }?.firstOrNull { it.checkItemStateId == itemId }?.freeTextValue
|
||||
|
||||
// --- Laden ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun unbekannte_dokumentation_zeigt_fehler_und_laedt_nicht_endlos() = runDbTest {
|
||||
val viewModel = createViewModel(id = "gibt-es-nicht")
|
||||
|
||||
assertEquals("Dokumentation nicht gefunden.", viewModel.uiState.value.error)
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datenbankfehler_beim_laden_zeigt_fehler_statt_dauerhaftem_ladezustand() = runDbTest {
|
||||
val failingDao = FailingDocumentationDao(env.db.documentationDao())
|
||||
.apply { loadFailure = IllegalStateException("Datenbank nicht verfügbar (Test)") }
|
||||
val repository = env.buildRepository(documentationDao = failingDao)
|
||||
|
||||
val viewModel = createViewModel(repository)
|
||||
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertNotNull(viewModel.uiState.value.error)
|
||||
assertNull(viewModel.uiState.value.documentation)
|
||||
}
|
||||
|
||||
// --- Autosave: Haken -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun haken_wird_bei_normalem_betrieb_gespeichert() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
val item = viewModel.plainItem()
|
||||
|
||||
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
|
||||
|
||||
awaitUntil {
|
||||
env.documentationRepository.getFullDocumentation(documentationId)!!
|
||||
.blocks.flatMap { it.items }.first { it.checkItemStateId == item.checkItemStateId }.checked
|
||||
}
|
||||
assertNull(viewModel.uiState.value.saveError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun haken_bei_vollem_speicher_meldet_fehler_und_zeigt_den_tatsaechlich_gespeicherten_stand() = runDbTest {
|
||||
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
|
||||
.apply { failure = SQLiteFullException("database or disk is full") }
|
||||
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
|
||||
val item = viewModel.plainItem()
|
||||
|
||||
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
|
||||
|
||||
awaitCondition {
|
||||
viewModel.uiState.value.saveError != null && !viewModel.itemById(item.checkItemStateId).checked
|
||||
}
|
||||
assertTrue(viewModel.uiState.value.saveError!!.contains("Speicherplatz"))
|
||||
assertFalse("Anzeige darf keinen nicht gespeicherten Haken zeigen", viewModel.itemById(item.checkItemStateId).checked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speicherfehler_beim_haken_laesst_die_app_nicht_abstuerzen_und_spaetere_aenderungen_funktionieren() = runDbTest {
|
||||
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
|
||||
.apply { failure = SQLiteFullException("database or disk is full") }
|
||||
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
|
||||
val item = viewModel.plainItem()
|
||||
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
|
||||
awaitCondition { viewModel.uiState.value.saveError != null }
|
||||
onMain { viewModel.dismissSaveError() }
|
||||
|
||||
failingDao.failure = null
|
||||
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
|
||||
|
||||
awaitUntil {
|
||||
env.documentationRepository.getFullDocumentation(documentationId)!!
|
||||
.blocks.flatMap { it.items }.first { it.checkItemStateId == item.checkItemStateId }.checked
|
||||
}
|
||||
assertNull(viewModel.uiState.value.saveError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dismissSaveError_leert_die_meldung() = runDbTest {
|
||||
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao()).apply { failure = IOException("x") }
|
||||
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
|
||||
onMain { viewModel.onCheckedChanged(viewModel.plainItem().checkItemStateId, true) }
|
||||
awaitCondition { viewModel.uiState.value.saveError != null }
|
||||
|
||||
onMain { viewModel.dismissSaveError() }
|
||||
|
||||
assertNull(viewModel.uiState.value.saveError)
|
||||
}
|
||||
|
||||
// --- Autosave: Freitext (Datenverlust verhindern) ------------------------------------
|
||||
|
||||
@Test
|
||||
fun freitext_wird_nach_debounce_gespeichert() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
val item = viewModel.freeTextItem()
|
||||
|
||||
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Wunde trocken") }
|
||||
|
||||
awaitUntil { storedFreeText(item.checkItemStateId) == "Wunde trocken" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freitext_bleibt_bei_speicherfehler_in_der_anzeige_erhalten_und_wird_spaeter_nachgeschrieben() = runDbTest {
|
||||
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
|
||||
.apply { failure = SQLiteFullException("database or disk is full") }
|
||||
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
|
||||
val item = viewModel.freeTextItem()
|
||||
|
||||
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Wichtiger Befund") }
|
||||
awaitCondition { viewModel.uiState.value.saveError != null }
|
||||
|
||||
assertEquals(
|
||||
"Der Nutzer darf seinen Text nicht verlieren",
|
||||
"Wichtiger Befund",
|
||||
viewModel.itemById(item.checkItemStateId).freeTextValue,
|
||||
)
|
||||
assertEquals("", storedFreeText(item.checkItemStateId))
|
||||
|
||||
// Speicher wieder verfügbar: der ON_STOP-Flush schreibt den bisher nicht gespeicherten Text.
|
||||
failingDao.failure = null
|
||||
onMain { viewModel.flushPendingChanges() }
|
||||
|
||||
awaitUntil { storedFreeText(item.checkItemStateId) == "Wichtiger Befund" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flush_bei_weiterhin_vollem_speicher_verwirft_den_text_nicht() = runDbTest {
|
||||
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
|
||||
.apply { failure = SQLiteFullException("database or disk is full") }
|
||||
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
|
||||
val item = viewModel.freeTextItem()
|
||||
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Text") }
|
||||
onMain { viewModel.flushPendingChanges() }
|
||||
awaitCondition { viewModel.uiState.value.saveError != null }
|
||||
onMain { viewModel.dismissSaveError() }
|
||||
|
||||
// Zweiter Flush nach erfolgreicher Freigabe von Speicher: der Text muss noch vorhanden sein.
|
||||
failingDao.failure = null
|
||||
onMain { viewModel.flushPendingChanges() }
|
||||
|
||||
awaitUntil { storedFreeText(item.checkItemStateId) == "Text" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speicherfehler_beendet_den_freitext_autosave_nicht_dauerhaft() = runDbTest {
|
||||
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
|
||||
.apply { failure = SQLiteFullException("database or disk is full") }
|
||||
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
|
||||
val item = viewModel.freeTextItem()
|
||||
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Erster Versuch") }
|
||||
awaitCondition { viewModel.uiState.value.saveError != null }
|
||||
|
||||
failingDao.failure = null
|
||||
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Zweiter Versuch") }
|
||||
|
||||
awaitUntil { storedFreeText(item.checkItemStateId) == "Zweiter Versuch" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flush_ohne_ausstehende_aenderungen_tut_nichts() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.flushPendingChanges() }
|
||||
|
||||
assertNull(viewModel.uiState.value.saveError)
|
||||
}
|
||||
|
||||
// --- Fotoimport ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun foto_import_bei_defekten_bildern_meldet_fehler_und_beendet_den_ladezustand() = runDbTest {
|
||||
val storage = FailingPhotoStorage().apply { importFailure = { IOException("Bild defekt (Test)") } }
|
||||
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
|
||||
|
||||
onMain { viewModel.onPhotosPicked(listOf(someUri, someUri)) }
|
||||
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.photoImportError!!.startsWith("2 von 2"))
|
||||
assertTrue(viewModel.uiState.value.documentation!!.photos.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foto_import_uebernimmt_gueltige_bilder_auch_wenn_ein_bild_fehlschlaegt() = runDbTest {
|
||||
val storage = FailingPhotoStorage().apply { importFailure = { call -> if (call == 2) IOException("defekt") else null } }
|
||||
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
|
||||
|
||||
onMain { viewModel.onPhotosPicked(listOf(someUri, someUri, someUri)) }
|
||||
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.photoImportError!!.startsWith("1 von 3"))
|
||||
assertEquals(2, viewModel.uiState.value.documentation!!.photos.size)
|
||||
assertEquals(2, env.db.photoDao().getForDocumentation(documentationId).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foto_import_bei_arbeitsspeichermangel_stuerzt_nicht_ab_und_nennt_den_grund() = runDbTest {
|
||||
val storage = FailingPhotoStorage().apply { importFailure = { OutOfMemoryError("Test") } }
|
||||
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
|
||||
|
||||
onMain { viewModel.onPhotosPicked(listOf(someUri)) }
|
||||
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.photoImportError!!.contains("Arbeitsspeicher"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foto_import_bei_vollem_geraetespeicher_nennt_den_grund() = runDbTest {
|
||||
val storage = FailingPhotoStorage().apply {
|
||||
importFailure = { IOException("write failed: ENOSPC (No space left on device)") }
|
||||
}
|
||||
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
|
||||
|
||||
onMain { viewModel.onPhotosPicked(listOf(someUri)) }
|
||||
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.photoImportError!!.contains("Speicherplatz"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kamera_aufraeumen_laeuft_auch_wenn_der_import_fehlschlaegt() = runDbTest {
|
||||
val storage = FailingPhotoStorage().apply { importFailure = { IOException("defekt") } }
|
||||
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
|
||||
val cleanedUp = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
onMain { viewModel.onCameraPhotoCaptured(someUri) { cleanedUp.set(true) } }
|
||||
awaitCondition { cleanedUp.get() && !viewModel.uiState.value.photoImportInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.photoImportError != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leere_auswahl_ist_kein_fehler() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.onPhotosPicked(emptyList()) }
|
||||
|
||||
assertNull(viewModel.uiState.value.photoImportError)
|
||||
assertFalse(viewModel.uiState.value.photoImportInProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehlende_kamera_app_wird_gemeldet() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.onCameraUnavailable() }
|
||||
|
||||
assertNotNull(viewModel.uiState.value.photoImportError)
|
||||
}
|
||||
|
||||
// --- Signatur ------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun signatur_bei_dateifehler_zeigt_meldung_und_laesst_den_dialog_offen() = runDbTest {
|
||||
val storage = FailingSignatureStorage().apply { saveFailure = IOException("defekt (Test)") }
|
||||
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
|
||||
onMain { viewModel.requestAddSignature() }
|
||||
|
||||
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
|
||||
awaitCondition { viewModel.uiState.value.signatureError != null }
|
||||
|
||||
assertTrue(viewModel.uiState.value.showAddSignatureDialog)
|
||||
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatur_bei_vollem_speicher_nennt_den_grund() = runDbTest {
|
||||
val storage = FailingSignatureStorage().apply {
|
||||
saveFailure = IOException("write failed: ENOSPC (No space left on device)")
|
||||
}
|
||||
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
|
||||
|
||||
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
|
||||
awaitCondition { viewModel.uiState.value.signatureError != null }
|
||||
|
||||
assertTrue(viewModel.uiState.value.signatureError!!.contains("Speicherplatz"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatur_bei_arbeitsspeichermangel_stuerzt_nicht_ab() = runDbTest {
|
||||
val storage = FailingSignatureStorage().apply { saveFailure = OutOfMemoryError("Test") }
|
||||
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
|
||||
|
||||
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
|
||||
awaitCondition { viewModel.uiState.value.signatureError != null }
|
||||
|
||||
assertTrue(viewModel.uiState.value.signatureError!!.contains("Arbeitsspeicher"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leere_signatur_wird_mit_fachlicher_meldung_abgelehnt() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.saveSignature("Pfleger Paul", blankBitmap()) }
|
||||
awaitCondition { viewModel.uiState.value.signatureError != null }
|
||||
|
||||
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatur_ohne_namen_wird_abgelehnt() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.saveSignature(" ", signatureBitmap()) }
|
||||
awaitCondition { viewModel.uiState.value.signatureError != null }
|
||||
|
||||
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
|
||||
}
|
||||
|
||||
// --- PDF-Export ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun pdf_export_fehler_beendet_ladezustand_und_bietet_kein_anzeigen_an() = runDbTest {
|
||||
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.WriteFailed("x")) }
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.exportPdf() }
|
||||
awaitCondition { viewModel.uiState.value.pdfExportMessage != null && !viewModel.uiState.value.pdfExportInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.pdfExportMessage!!.contains("fehlgeschlagen"))
|
||||
assertNull(viewModel.uiState.value.lastExportedPdfFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pdf_export_mit_speichermangel_im_exporter_haengt_nicht_und_stuerzt_nicht_ab() = runDbTest {
|
||||
val repository = env.buildRepository(pdfExporter = ThrowingPdfExporter(OutOfMemoryError("Test")))
|
||||
val viewModel = createViewModel(repository)
|
||||
|
||||
onMain { viewModel.exportPdf() }
|
||||
awaitCondition { viewModel.uiState.value.pdfExportMessage != null && !viewModel.uiState.value.pdfExportInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.pdfExportMessage!!.contains("Arbeitsspeicher"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pdf_export_erfolg_stellt_die_datei_bereit() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.exportPdf() }
|
||||
awaitCondition { viewModel.uiState.value.lastExportedPdfFile != null && !viewModel.uiState.value.pdfExportInProgress }
|
||||
|
||||
assertNotNull(viewModel.uiState.value.lastExportedPdfFile)
|
||||
}
|
||||
|
||||
// --- Teilen --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun teilen_als_pdf_bei_vollem_speicher_zeigt_meldung_und_teilt_nichts() = runDbTest {
|
||||
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.InsufficientStorage) }
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.shareAsPdf() }
|
||||
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.shareMessage!!.contains("Speicherplatz"))
|
||||
assertNull(viewModel.uiState.value.pdfToShare)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun teilen_als_bilder_bei_konvertierungsfehler_zeigt_meldung_und_teilt_nichts() = runDbTest {
|
||||
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.OutOfMemory
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.shareAsImages() }
|
||||
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
|
||||
|
||||
assertTrue(viewModel.uiState.value.shareMessage!!.startsWith("Bildexport fehlgeschlagen"))
|
||||
assertNull(viewModel.uiState.value.imagesToShare)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun teilen_als_bilder_bei_export_fehler_ueberspringt_die_konvertierung() = runDbTest {
|
||||
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.OutOfMemory) }
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.shareAsImages() }
|
||||
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
|
||||
|
||||
assertTrue(env.pdfToImagesConverter.calls.isEmpty())
|
||||
assertNull(viewModel.uiState.value.imagesToShare)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun teilen_als_pdf_erfolg_stellt_die_datei_bereit() = runDbTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
onMain { viewModel.shareAsPdf() }
|
||||
awaitCondition { viewModel.uiState.value.pdfToShare != null && !viewModel.uiState.value.shareInProgress }
|
||||
|
||||
assertNull(viewModel.uiState.value.shareMessage)
|
||||
assertTrue(viewModel.uiState.value.pdfToShare is File)
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package de.medapp.wunddoku.ui.patients.form
|
||||
|
||||
import android.database.sqlite.SQLiteFullException
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||
import de.medapp.wunddoku.testutil.FailingDocumentationDao
|
||||
import de.medapp.wunddoku.testutil.FailingPatientDao
|
||||
import de.medapp.wunddoku.testutil.TestEnvironment
|
||||
import de.medapp.wunddoku.testutil.awaitCondition
|
||||
import de.medapp.wunddoku.testutil.buildRepository
|
||||
import de.medapp.wunddoku.testutil.onMain
|
||||
import de.medapp.wunddoku.testutil.runDbTest
|
||||
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
|
||||
|
||||
/**
|
||||
* Fehlerbehandlung beim Speichern im [PatientFormViewModel]: voller Speicher/Datenbankfehler
|
||||
* dürfen weder abstürzen noch zu einer scheinbar erfolgreichen Navigation führen, und die
|
||||
* Eingaben des Nutzers bleiben im Formular erhalten.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PatientFormViewModelErrorHandlingTest {
|
||||
|
||||
private lateinit var env: TestEnvironment
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
env = TestEnvironment()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
env.close()
|
||||
}
|
||||
|
||||
private fun fill(viewModel: PatientFormViewModel) = onMain {
|
||||
viewModel.onFirstNameChanged("Max")
|
||||
viewModel.onLastNameChanged("Mustermann")
|
||||
viewModel.onBirthDateDigitsChanged("14031980")
|
||||
viewModel.onStreetChanged("Musterstraße 1")
|
||||
viewModel.onPostalCodeChanged("12345")
|
||||
viewModel.onCityChanged("Musterstadt")
|
||||
viewModel.onPhone1Changed("0123456789")
|
||||
}
|
||||
|
||||
private suspend fun createViewModel(
|
||||
patientRepository: PatientRepository,
|
||||
startsNewDocumentation: Boolean,
|
||||
documentationDao: FailingDocumentationDao? = null,
|
||||
): PatientFormViewModel {
|
||||
val documentationRepository = if (documentationDao != null) {
|
||||
env.buildRepository(documentationDao = documentationDao)
|
||||
} else {
|
||||
env.documentationRepository
|
||||
}
|
||||
val viewModel = onMain {
|
||||
PatientFormViewModel(
|
||||
patientId = null,
|
||||
startsNewDocumentation = startsNewDocumentation,
|
||||
repository = patientRepository,
|
||||
documentationRepository = documentationRepository,
|
||||
)
|
||||
}
|
||||
awaitCondition { !viewModel.uiState.value.isLoading }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speicherfehler_beim_anlegen_des_patienten_zeigt_meldung_und_behaelt_die_eingaben() = runDbTest {
|
||||
val failingDao = FailingPatientDao(env.db.patientDao())
|
||||
.apply { insertFailure = SQLiteFullException("database or disk is full") }
|
||||
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = true)
|
||||
fill(viewModel)
|
||||
|
||||
onMain { viewModel.save() }
|
||||
awaitCondition { viewModel.uiState.value.validationError != null }
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.validationError!!.contains("Speicherplatz"))
|
||||
assertFalse(state.patientSaved)
|
||||
assertNull(state.createdDocumentationId)
|
||||
assertEquals("Max", state.firstName)
|
||||
assertEquals("Mustermann", state.lastName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sonstiger_datenbankfehler_beim_speichern_stuerzt_nicht_ab() = runDbTest {
|
||||
val failingDao = FailingPatientDao(env.db.patientDao())
|
||||
.apply { insertFailure = IllegalStateException("Test") }
|
||||
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
|
||||
fill(viewModel)
|
||||
|
||||
onMain { viewModel.save() }
|
||||
awaitCondition { viewModel.uiState.value.validationError != null }
|
||||
|
||||
assertFalse(viewModel.uiState.value.patientSaved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fehler_beim_anlegen_der_dokumentation_meldet_fehler_ohne_navigation() = runDbTest {
|
||||
val failingDocDao = FailingDocumentationDao(env.db.documentationDao())
|
||||
.apply { loadFailure = SQLiteFullException("database or disk is full") }
|
||||
val viewModel = createViewModel(
|
||||
env.patientRepository,
|
||||
startsNewDocumentation = true,
|
||||
documentationDao = failingDocDao,
|
||||
)
|
||||
fill(viewModel)
|
||||
|
||||
onMain { viewModel.save() }
|
||||
awaitCondition { viewModel.uiState.value.validationError != null }
|
||||
|
||||
assertNull("keine Navigation zu einer nicht angelegten Dokumentation", viewModel.uiState.value.createdDocumentationId)
|
||||
assertNotNull(viewModel.uiState.value.validationError)
|
||||
// Die Dokumentation wurde per Transaktion zurückgerollt: es gibt keine halbe Dokumentation.
|
||||
assertEquals(0, env.db.documentationDao().countForPatient(env.db.patientDao().findExisting("Max", "Mustermann", "1980-03-14")!!.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speichern_funktioniert_nach_einem_fehler_erneut() = runDbTest {
|
||||
val failingDao = FailingPatientDao(env.db.patientDao())
|
||||
.apply { insertFailure = IllegalStateException("Test") }
|
||||
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
|
||||
fill(viewModel)
|
||||
onMain { viewModel.save() }
|
||||
awaitCondition { viewModel.uiState.value.validationError != null }
|
||||
|
||||
failingDao.insertFailure = null
|
||||
onMain { viewModel.save() }
|
||||
awaitCondition { viewModel.uiState.value.patientSaved }
|
||||
|
||||
assertNull(viewModel.uiState.value.validationError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ungueltige_eingaben_werden_weiterhin_fachlich_abgelehnt() = runDbTest {
|
||||
val viewModel = createViewModel(env.patientRepository, startsNewDocumentation = false)
|
||||
|
||||
onMain { viewModel.save() }
|
||||
|
||||
assertNotNull(viewModel.uiState.value.validationError)
|
||||
assertFalse(viewModel.uiState.value.patientSaved)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der
|
||||
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
|
||||
PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. -->
|
||||
|
||||
<!-- Die App arbeitet vollständig offline. Falls eine Bibliothek (jetzt oder nach einem
|
||||
Update) die INTERNET-Berechtigung mitbringt, wird sie beim Zusammenführen der Manifeste
|
||||
entfernt - Patientendaten können das Gerät so nicht über das Netzwerk verlassen. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
|
||||
<application
|
||||
android:name=".WunddokuApplication"
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -2,14 +2,25 @@ package de.medapp.wunddoku
|
||||
|
||||
import android.app.Application
|
||||
import de.medapp.wunddoku.di.AppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class WunddokuApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
|
||||
// Datenschutz: veraltete PDF-/Bildexporte und liegengebliebene Kameraaufnahmen entfernen
|
||||
// (Aufbewahrungsfristen siehe InternalExportedFilesCleaner). Läuft im Hintergrund,
|
||||
// wirft nie und verzögert den Start nicht.
|
||||
applicationScope.launch { container.exportedFilesCleaner.deleteStaleFiles() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,16 +18,29 @@ import de.medapp.wunddoku.data.entity.SignatureEntity
|
||||
import de.medapp.wunddoku.data.mapper.toDomain
|
||||
import de.medapp.wunddoku.data.mapper.toSummary
|
||||
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
|
||||
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||
import de.medapp.wunddoku.domain.error.runCatchingCancellable
|
||||
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.ExportedFilesCleaner
|
||||
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.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@@ -39,6 +52,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,
|
||||
@@ -48,8 +65,12 @@ 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,
|
||||
private val exportedFilesCleaner: ExportedFilesCleaner = ExportedFilesCleaner.None,
|
||||
) {
|
||||
|
||||
fun observeAll(): Flow<List<DocumentationSummary>> =
|
||||
@@ -113,8 +134,10 @@ 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,
|
||||
@@ -172,7 +195,9 @@ class DocumentationRepository(
|
||||
itemOrder = item.itemOrder,
|
||||
checked = item.checked,
|
||||
isFreeText = item.isFreeText,
|
||||
isStaticHint = item.isStaticHint,
|
||||
freeTextValue = item.freeTextValue,
|
||||
inputSpec = item.inputSpec,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -181,6 +206,7 @@ class DocumentationRepository(
|
||||
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
|
||||
|
||||
if (copyPhotos) {
|
||||
try {
|
||||
for (sourcePhoto in source.photos) {
|
||||
val copiedFileName = photoStorage.duplicate(
|
||||
sourceDocumentationId = source.documentation.id,
|
||||
@@ -197,6 +223,13 @@ class DocumentationRepository(
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Die Room-Transaktion rollt die DB-Zeilen zurück - bereits kopierte Fotodateien
|
||||
// der nie entstandenen Dokumentation würden aber verwaist im Speicher bleiben
|
||||
// (bei Speichermangel besonders ärgerlich). Aufräumen, dann Fehler weiterreichen.
|
||||
runCatching { photoStorage.deleteAll(newDocumentationId) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain()
|
||||
@@ -230,16 +263,22 @@ class DocumentationRepository(
|
||||
|
||||
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
|
||||
val fileName = photoStorage.importFrom(documentationId, sourceUri)
|
||||
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||
val now = Instant.now()
|
||||
val entity = PhotoEntity(
|
||||
val entity = try {
|
||||
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||
PhotoEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
documentationId = documentationId,
|
||||
fileName = fileName,
|
||||
orderIndex = nextOrder,
|
||||
createdAt = now.toEpochMilli(),
|
||||
)
|
||||
photoDao.insert(entity)
|
||||
).also { photoDao.insert(it) }
|
||||
} catch (e: Throwable) {
|
||||
// DB-Eintrag fehlgeschlagen (z. B. Datenbank voll): die gerade importierte Datei
|
||||
// wäre sonst eine verwaiste Datei ohne Referenz.
|
||||
runCatching { photoStorage.delete(documentationId, fileName) }
|
||||
throw e
|
||||
}
|
||||
return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now)
|
||||
}
|
||||
|
||||
@@ -255,6 +294,53 @@ class DocumentationRepository(
|
||||
runCatching { photoStorage.delete(photo.documentationId, photo.fileName) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return try {
|
||||
val documentation = getFullDocumentation(documentationId)
|
||||
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
|
||||
val model = DocumentationToPdfExportModelMapper.map(documentation)
|
||||
pdfExporter.export(model)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Vertrag: der Export wirft nie - weder bei Datenbankfehlern noch bei Speichermangel.
|
||||
// Technische Fehlertexte werden bewusst nicht weitergereicht (Datenschutz).
|
||||
when (FailureCategory.of(e)) {
|
||||
FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory)
|
||||
FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage)
|
||||
FailureCategory.OTHER -> PdfExportResult.Failure(PdfExportError.Unexpected(e::class.java.simpleName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
try {
|
||||
pdfToImagesConverter.convert(documentationId, pdfFile)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
when (FailureCategory.of(e)) {
|
||||
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
|
||||
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
|
||||
FailureCategory.OTHER -> PdfToImagesResult.Failure.Unexpected(e::class.java.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
|
||||
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
|
||||
@@ -264,44 +350,127 @@ class DocumentationRepository(
|
||||
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
|
||||
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
|
||||
|
||||
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature {
|
||||
val fileName = signatureFileStorage.save(bitmap)
|
||||
/**
|
||||
* 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 now = Instant.now()
|
||||
val entity = SignatureEntity(
|
||||
val entity = try {
|
||||
SignatureEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
documentationId = documentationId,
|
||||
signerName = signerName,
|
||||
signerName = trimmedName,
|
||||
fileName = fileName,
|
||||
orderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1,
|
||||
createdAt = now.toEpochMilli(),
|
||||
)
|
||||
signatureDao.insert(entity)
|
||||
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now)
|
||||
).also { signatureDao.insert(it) }
|
||||
} catch (e: Throwable) {
|
||||
runCatching { signatureStorage.delete(documentationId, fileName) }
|
||||
throw e
|
||||
}
|
||||
// Das Wundversorger-Register ist nur eine Komfortfunktion (Namensvorschläge). Scheitert
|
||||
// es, ist die Signatur der Dokumentation trotzdem bereits sicher gespeichert - der
|
||||
// Nutzer darf dann keine Fehlermeldung bekommen, die ihn die Signatur erneut erfassen lässt.
|
||||
runCatchingCancellable { 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 signatures = signatureDao.getForDocumentation(documentationId)
|
||||
documentationDao.delete(entity)
|
||||
// Ein einziger Verzeichnis-Löschvorgang statt Einzeldateien - siehe PhotoStorage-
|
||||
// Kommentar zur dokumentationsbezogenen Ordnerstruktur. Room-Cascade entfernt zwar die
|
||||
// PhotoEntity-Zeilen automatisch, aber keine Dateien aus filesDir - das übernimmt
|
||||
// ausschließlich diese Methode.
|
||||
// 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) }
|
||||
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } }
|
||||
runCatching { signatureStorage.deleteAll(documentationId) }
|
||||
// Datenschutz (Löschkonzept): Auch abgeleitete Kopien der Patientendaten - exportierte
|
||||
// PDFs und deren Seitenbilder - müssen mit der Dokumentation verschwinden.
|
||||
runCatching { exportedFilesCleaner.deleteExportsFor(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) {
|
||||
@@ -319,29 +488,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,17 @@ 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.ExportedFilesCleaner
|
||||
import de.medapp.wunddoku.pdf.InternalExportedFilesCleaner
|
||||
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 +33,33 @@ 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) }
|
||||
|
||||
/** Löscht Exporte gelöschter Dokumentationen sowie veraltete Exporte/Kameraaufnahmen. */
|
||||
val exportedFilesCleaner: ExportedFilesCleaner by lazy {
|
||||
InternalExportedFilesCleaner(filesDir = context.filesDir, cacheDir = context.cacheDir)
|
||||
}
|
||||
|
||||
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,8 +69,12 @@ class AppContainer(context: Context) {
|
||||
signatureDao = database.signatureDao(),
|
||||
patientDao = database.patientDao(),
|
||||
photoStorage = photoStorage,
|
||||
signatureFileStorage = signatureFileStorage,
|
||||
signatureStorage = signatureStorage,
|
||||
woundCarerRepository = woundCarerRepository,
|
||||
pdfExporter = pdfExporter,
|
||||
pdfToImagesConverter = pdfToImagesConverter,
|
||||
db = database,
|
||||
exportedFilesCleaner = exportedFilesCleaner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
|
||||
*/
|
||||
object DocumentationCatalog {
|
||||
|
||||
const val CATALOG_VERSION: Int = 1
|
||||
const val CATALOG_VERSION: Int = 5
|
||||
|
||||
/**
|
||||
* 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",
|
||||
)
|
||||
|
||||
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),
|
||||
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),
|
||||
// Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
|
||||
// Checkbox aktiv ist (= vorhandenes isFreeText-Verhalten, s. FreeTextCheckItemRow).
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5, isFreeText = true),
|
||||
CheckItemDefinition(
|
||||
id = "empfohlene_wundversorgung_getraenkte_kompresse",
|
||||
label = "Getränkte Kompresse",
|
||||
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),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 16),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 17),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_freitext", label = FREE_TEXT_LABEL, order = 18, isFreeText = true),
|
||||
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
|
||||
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
|
||||
CheckItemDefinition(
|
||||
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
|
||||
label = "Wie oft pro Woche",
|
||||
order = 19,
|
||||
inputSpec = InputSpec(
|
||||
pdfTemplate = "{0}x pro Woche",
|
||||
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
|
||||
requiresOwnCheckbox = true,
|
||||
),
|
||||
),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 20, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 21),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 32, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 33, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 34, isStaticHint = true),
|
||||
CheckItemDefinition(
|
||||
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
||||
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
||||
order = 35,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.medapp.wunddoku.domain.error
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* Grobe Einordnung eines Fehlers für die Anzeige und Weiterbehandlung.
|
||||
*
|
||||
* Bewusst rein in Kotlin/JVM gehalten (keine Android-Klassen), damit die Einordnung per
|
||||
* einfachem JVM-Unit-Test prüfbar ist. Android-spezifische Typen (z. B. `SQLiteFullException`)
|
||||
* werden deshalb über Klassenname bzw. Meldungstext erkannt.
|
||||
*/
|
||||
enum class FailureCategory {
|
||||
/** Arbeitsspeicher erschöpft ([OutOfMemoryError]). */
|
||||
OUT_OF_MEMORY,
|
||||
|
||||
/** Geräte-/Datenbankspeicher voll (ENOSPC, `SQLiteFullException`, "disk is full"). */
|
||||
OUT_OF_STORAGE,
|
||||
|
||||
/** Alles andere. */
|
||||
OTHER,
|
||||
;
|
||||
|
||||
companion object {
|
||||
private const val SQLITE_FULL_EXCEPTION = "android.database.sqlite.SQLiteFullException"
|
||||
private val STORAGE_FULL_MARKERS = listOf(
|
||||
"no space left on device",
|
||||
"enospc",
|
||||
"database or disk is full",
|
||||
"disk full",
|
||||
"sqlite_full",
|
||||
)
|
||||
|
||||
/** Durchsucht [throwable] samt gesamter Ursachenkette. */
|
||||
fun of(throwable: Throwable): FailureCategory {
|
||||
val seen = HashSet<Throwable>()
|
||||
var current: Throwable? = throwable
|
||||
while (current != null && seen.add(current)) {
|
||||
if (current is OutOfMemoryError) return OUT_OF_MEMORY
|
||||
if (current.javaClass.name == SQLITE_FULL_EXCEPTION) return OUT_OF_STORAGE
|
||||
val message = current.message?.lowercase()
|
||||
if (message != null && STORAGE_FULL_MARKERS.any { message.contains(it) }) return OUT_OF_STORAGE
|
||||
current = current.cause
|
||||
}
|
||||
return OTHER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie [runCatching], reicht aber eine Coroutine-Abbruch-Exception immer weiter - ein
|
||||
* abgebrochener Vorgang darf nicht als "Fehler" verschluckt werden.
|
||||
*/
|
||||
inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
|
||||
Result.success(block())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
@@ -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,173 @@
|
||||
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>,
|
||||
/**
|
||||
* `true`: der Eintrag hat zusätzlich eine EIGENE Checkbox mit dem Label des Eintrags (z. B.
|
||||
* "Wie oft pro Woche"). Die Eingabefelder sind dann nur aktiv, solange diese Checkbox
|
||||
* angehakt ist; das Häkchen wird NICHT mehr automatisch aus den Feldwerten abgeleitet,
|
||||
* sondern ausschließlich vom Nutzer gesetzt. Standard `false` = bisheriges Verhalten
|
||||
* (Felder immer aktiv, Häkchen automatisch bei mindestens einem Wert).
|
||||
*/
|
||||
val requiresOwnCheckbox: Boolean = false,
|
||||
) {
|
||||
|
||||
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
|
||||
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 ... [␞ B]`, 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'
|
||||
|
||||
/** Optionaler Abschluss-Datensatz "eigene Checkbox" (s. [InputSpec.requiresOwnCheckbox]).
|
||||
* Ältere Decoder ignorieren unbekannte Datensätze, bereits gespeicherte Snapshots ohne
|
||||
* diesen Datensatz werden mit `requiresOwnCheckbox = false` gelesen. */
|
||||
private const val OWN_CHECKBOX_RECORD = "B"
|
||||
|
||||
fun encode(spec: InputSpec): String {
|
||||
val records = mutableListOf(spec.pdfTemplate)
|
||||
for (field in spec.fields) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
if (spec.requiresOwnCheckbox) records += OWN_CHECKBOX_RECORD
|
||||
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,
|
||||
requiresOwnCheckbox = records.drop(1).any { it == OWN_CHECKBOX_RECORD },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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, organisiert pro Dokumentation:
|
||||
@@ -86,6 +87,27 @@ interface PhotoStorage {
|
||||
* 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 {
|
||||
@@ -95,13 +117,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
}
|
||||
|
||||
private fun documentationDirPath(documentationId: String): File =
|
||||
File(photosRootDir, documentationId)
|
||||
SafePaths.resolveChild(photosRootDir, documentationId)
|
||||
|
||||
private fun ensureDocumentationDir(documentationId: String): File =
|
||||
documentationDirPath(documentationId).apply { mkdirs() }
|
||||
|
||||
override fun resolve(documentationId: String, fileName: String): File =
|
||||
File(documentationDirPath(documentationId), fileName)
|
||||
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
|
||||
|
||||
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -150,21 +172,36 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||
} ?: ExifInterface.ORIENTATION_NORMAL
|
||||
|
||||
val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation)
|
||||
val corrected = try {
|
||||
ImageDownsampler.applyExifOrientation(decoded, orientation)
|
||||
} catch (e: Throwable) {
|
||||
decoded.recycle()
|
||||
throw e
|
||||
}
|
||||
|
||||
// 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.
|
||||
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos.
|
||||
//
|
||||
// Fehlerbehandlung: Bitmap.compress() liefert bei einem Schreibfehler (z. B. Speicher
|
||||
// voll) nur `false` statt zu werfen - das wird hier zu einer Exception, und eine
|
||||
// halb geschriebene Datei wird in jedem Fehlerfall wieder entfernt, damit nie eine
|
||||
// defekte Bilddatei im Dokumentationsordner liegen bleibt.
|
||||
val targetDir = ensureDocumentationDir(documentationId)
|
||||
val fileName = "${UUID.randomUUID()}.jpg"
|
||||
File(targetDir, fileName).outputStream().use { out ->
|
||||
val targetFile = File(targetDir, fileName)
|
||||
try {
|
||||
val written = targetFile.outputStream().use { out ->
|
||||
corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
|
||||
}
|
||||
|
||||
if (!written) throw java.io.IOException("Bild konnte nicht gespeichert werden.")
|
||||
} catch (e: Throwable) {
|
||||
targetFile.delete()
|
||||
throw e
|
||||
} finally {
|
||||
if (corrected !== decoded) decoded.recycle()
|
||||
corrected.recycle()
|
||||
}
|
||||
|
||||
fileName
|
||||
}
|
||||
@@ -177,7 +214,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
val sourceFile = resolve(sourceDocumentationId, sourceFileName)
|
||||
val targetFileName = "${UUID.randomUUID()}.jpg"
|
||||
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
|
||||
try {
|
||||
sourceFile.copyTo(targetFile, overwrite = false)
|
||||
} catch (e: Throwable) {
|
||||
// Keine halb kopierte Datei zurücklassen.
|
||||
targetFile.delete()
|
||||
throw e
|
||||
}
|
||||
targetFileName
|
||||
}
|
||||
|
||||
@@ -212,4 +255,59 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
)
|
||||
}.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,32 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Schutz vor Path-Traversal (`../`) beim Auflösen von Datei-/Ordnernamen im privaten App-Speicher.
|
||||
*
|
||||
* Datei- und Ordnernamen stammen normalerweise aus der eigenen Datenbank (UUIDs). Als
|
||||
* Defense-in-Depth wird trotzdem sichergestellt, dass ein aufgelöster Pfad nie aus seinem
|
||||
* Wurzelordner ausbrechen kann - etwa bei einer manipulierten Datenbank oder einem Programmfehler.
|
||||
*/
|
||||
object SafePaths {
|
||||
|
||||
/**
|
||||
* Liefert `File(parent, name)`, wenn [name] ein einzelner, harmloser Pfadbestandteil ist.
|
||||
*
|
||||
* @throws IllegalArgumentException bei leerem Namen, Pfadtrennzeichen, "." / ".." oder wenn
|
||||
* der kanonische Pfad nicht unterhalb von [parent] liegt.
|
||||
*/
|
||||
fun resolveChild(parent: File, name: String): File {
|
||||
require(name.isNotBlank()) { "Name darf nicht leer sein." }
|
||||
require(!name.contains('/') && !name.contains('\\') && !name.contains('\u0000')) {
|
||||
"Name darf kein Pfadtrennzeichen enthalten."
|
||||
}
|
||||
require(name != "." && name != "..") { "Ungültiger Name." }
|
||||
val child = File(parent, name)
|
||||
val parentPath = parent.canonicalFile.path
|
||||
val childPath = child.canonicalFile.path
|
||||
require(childPath.startsWith(parentPath + File.separator)) { "Pfad liegt außerhalb des erlaubten Ordners." }
|
||||
return child
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import android.text.TextPaint
|
||||
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||
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
|
||||
|
||||
/**
|
||||
* Änderungswunsch: Bilder im Fotoanhang werden auf 50 % ihrer bisherigen Größe
|
||||
* ausgegeben (Breite und Höhe je halbiert, Seitenverhältnis unverändert). Derselbe
|
||||
* Faktor gilt für die Decode-Auflösung (s. [PHOTO_MAX_WIDTH_PX]), sodass die
|
||||
* Bilddichte bei [PHOTO_TARGET_DPI] bleibt und das PDF zugleich deutlich kleiner wird.
|
||||
*/
|
||||
private const val PHOTO_SIZE_FACTOR = 0.5f
|
||||
|
||||
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
|
||||
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
|
||||
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
|
||||
// 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] und [PHOTO_SIZE_FACTOR] (≈ 536px bei 257,5pt/150dpi). */
|
||||
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
|
||||
|
||||
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 793px bei 381pt/150dpi). */
|
||||
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
|
||||
|
||||
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
}
|
||||
|
||||
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: Throwable) {
|
||||
tempFile.delete()
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
// Bewusst Throwable: OutOfMemoryError ist ein Error, keine Exception, und würde
|
||||
// sonst die App beenden. Eine bereits vorhandene PDF am Zielpfad bleibt unangetastet.
|
||||
when (FailureCategory.of(e)) {
|
||||
FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory)
|
||||
FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage)
|
||||
FailureCategory.OTHER -> 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
|
||||
}
|
||||
// Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv.
|
||||
val blockTitle = TextPaint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = BLOCK_TITLE_TEXT_SIZE
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
|
||||
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)
|
||||
}
|
||||
|
||||
/** Überschriften erscheinen im PDF immer mit abschließendem Doppelpunkt (kein doppelter
|
||||
* Doppelpunkt, falls der Titel bereits mit einem endet). */
|
||||
private fun headingText(title: String): String {
|
||||
val trimmed = title.trimEnd()
|
||||
return if (trimmed.endsWith(":")) trimmed else "$trimmed:"
|
||||
}
|
||||
|
||||
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
|
||||
cursor.drawText(headingText(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) {
|
||||
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"), angehakt aber ohne
|
||||
// Auswahl: nur das Label ausgeben statt einer Vorlage mit "–"-Platzhaltern.
|
||||
val itemText = if (decodedInputSpec.requiresOwnCheckbox && !decodedInputSpec.hasAnyValue(item.freeTextValue)) {
|
||||
item.label
|
||||
} else {
|
||||
decodedInputSpec.formatForPdf(item.freeTextValue)
|
||||
}
|
||||
cursor.drawText(
|
||||
text = itemText,
|
||||
paint = paints.item,
|
||||
indent = ITEM_INDENT,
|
||||
spacingAfter = 3f,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
|
||||
// folgt (z. B. "Granudacyn" ohne Text bleibt ohne hängenden Doppelpunkt), normale
|
||||
// Optionen nie.
|
||||
val labelText = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
|
||||
|
||||
cursor.drawText(
|
||||
text = labelText,
|
||||
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(headingText("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(headingText("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, sizeFactor = PHOTO_SIZE_FACTOR)
|
||||
} 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,62 @@
|
||||
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
|
||||
|
||||
/** Arbeitsspeicher reichte für die PDF-Erzeugung nicht aus (z. B. sehr viele Fotos). */
|
||||
data object OutOfMemory : PdfExportError
|
||||
|
||||
/** Auf dem Gerät ist nicht genug freier Speicherplatz für die PDF-Datei vorhanden. */
|
||||
data object InsufficientStorage : PdfExportError
|
||||
}
|
||||
|
||||
/**
|
||||
* Verständliche Anzeige-Meldung ohne technische Details (keine Pfade, keine Exception-Texte -
|
||||
* Datenschutz: es können Patientendaten im Dateinamen/Pfad stecken).
|
||||
*/
|
||||
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."
|
||||
PdfExportError.OutOfMemory ->
|
||||
"Nicht genügend Arbeitsspeicher. Bitte andere Apps schließen oder weniger Fotos verwenden."
|
||||
PdfExportError.InsufficientStorage ->
|
||||
"Nicht genügend freier Speicherplatz auf dem Gerät."
|
||||
}
|
||||
@@ -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,80 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import de.medapp.wunddoku.media.SafePaths
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Löscht abgeleitete, personenbezogene Dateien, die NICHT zur Dokumentation selbst gehören:
|
||||
* exportierte PDFs (`filesDir/pdf/<documentationId>/`), daraus erzeugte Seitenbilder
|
||||
* (`filesDir/pdf_images/<documentationId>/`) und liegengebliebene Kameraaufnahmen
|
||||
* (`cacheDir/camera/`).
|
||||
*
|
||||
* Datenschutz: PDFs enthalten Name, Geburtsdatum, Anschrift und Wundbefund. Sie dürfen nach dem
|
||||
* Löschen einer Dokumentation nicht im Speicher zurückbleiben (Löschkonzept) und werden darüber
|
||||
* hinaus nach einer Aufbewahrungsfrist automatisch entfernt - sie lassen sich jederzeit aus den
|
||||
* Daten der Dokumentation neu erzeugen.
|
||||
*/
|
||||
interface ExportedFilesCleaner {
|
||||
|
||||
/** Entfernt alle PDF- und Bildexporte der Dokumentation [documentationId]. Wirft nie. */
|
||||
suspend fun deleteExportsFor(documentationId: String)
|
||||
|
||||
/** Entfernt veraltete Exporte und liegengebliebene Kameraaufnahmen. Wirft nie. */
|
||||
suspend fun deleteStaleFiles()
|
||||
|
||||
/** Tut nichts (Standard für Tests und Umgebungen ohne Dateisystem). */
|
||||
object None : ExportedFilesCleaner {
|
||||
override suspend fun deleteExportsFor(documentationId: String) = Unit
|
||||
override suspend fun deleteStaleFiles() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
class InternalExportedFilesCleaner(
|
||||
filesDir: File,
|
||||
cacheDir: File,
|
||||
private val exportMaxAgeMillis: Long = DEFAULT_EXPORT_MAX_AGE_MILLIS,
|
||||
private val captureMaxAgeMillis: Long = DEFAULT_CAPTURE_MAX_AGE_MILLIS,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ExportedFilesCleaner {
|
||||
|
||||
private val pdfRoot = File(filesDir, "pdf")
|
||||
private val imagesRoot = File(filesDir, "pdf_images")
|
||||
private val captureDir = File(cacheDir, "camera")
|
||||
|
||||
override suspend fun deleteExportsFor(documentationId: String) = withContext(Dispatchers.IO) {
|
||||
listOf(pdfRoot, imagesRoot).forEach { root ->
|
||||
runCatching { SafePaths.resolveChild(root, documentationId).deleteRecursively() }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun deleteStaleFiles() = withContext(Dispatchers.IO) {
|
||||
val currentTime = now()
|
||||
runCatching { deleteOlderThan(pdfRoot, currentTime - exportMaxAgeMillis) }
|
||||
runCatching { deleteOlderThan(imagesRoot, currentTime - exportMaxAgeMillis) }
|
||||
// Kameraaufnahmen nur nach längerer Frist löschen: Wurde die App während der Aufnahme
|
||||
// beendet, wird die Datei nach dem Neustart noch für den Import gebraucht.
|
||||
runCatching { deleteOlderThan(captureDir, currentTime - captureMaxAgeMillis) }
|
||||
Unit
|
||||
}
|
||||
|
||||
/** Löscht Dateien (rekursiv) mit `lastModified < cutoff`; leere Unterordner werden entfernt. */
|
||||
private fun deleteOlderThan(dir: File, cutoff: Long) {
|
||||
val children = dir.listFiles() ?: return
|
||||
for (child in children) {
|
||||
if (child.isDirectory) {
|
||||
deleteOlderThan(child, cutoff)
|
||||
if (child.listFiles().isNullOrEmpty()) child.delete()
|
||||
} else if (child.lastModified() < cutoff) {
|
||||
child.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_EXPORT_MAX_AGE_MILLIS: Long = 7L * 24 * 60 * 60 * 1000
|
||||
const val DEFAULT_CAPTURE_MAX_AGE_MILLIS: Long = 24L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -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,305 @@
|
||||
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, sizeFactor: Float = 1f) {
|
||||
startIfNeeded()
|
||||
val availableWidth = contentWidth.toFloat()
|
||||
val availableHeight = remainingHeight
|
||||
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
|
||||
|
||||
// [sizeFactor] < 1 (Änderungswunsch: Fotoanhang auf 50 %): das Bild wird zuerst wie
|
||||
// gewohnt in den verfügbaren Platz eingepasst und dann um diesen Faktor verkleinert
|
||||
// (Seitenverhältnis bleibt). Das verkleinerte Bild steht horizontal zentriert direkt
|
||||
// unter der Bildunterschrift (oben ausgerichtet) statt mitten in der Restfläche.
|
||||
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) * sizeFactor
|
||||
val drawWidth = bitmap.width * scale
|
||||
val drawHeight = bitmap.height * scale
|
||||
val left = marginLeft + (availableWidth - drawWidth) / 2f
|
||||
val top = if (sizeFactor < 1f) cursorY else 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,137 @@
|
||||
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 de.medapp.wunddoku.domain.error.FailureCategory
|
||||
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
|
||||
|
||||
/** Arbeitsspeicher reichte zum Rendern der Seiten nicht aus. */
|
||||
data object OutOfMemory : Failure
|
||||
|
||||
/** Nicht genug freier Speicherplatz für die Bilddateien. */
|
||||
data object InsufficientStorage : 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."
|
||||
PdfToImagesResult.Failure.OutOfMemory ->
|
||||
"Nicht genügend Arbeitsspeicher zum Umwandeln in Bilder."
|
||||
PdfToImagesResult.Failure.InsufficientStorage ->
|
||||
"Nicht genügend freier Speicherplatz für die Bilder."
|
||||
}
|
||||
|
||||
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: Throwable) {
|
||||
// Abbruch der Coroutine nie verschlucken.
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
// Halbfertigen Bildsatz entfernen: ein unvollständiger Satz darf nie geteilt werden.
|
||||
outputDir.deleteRecursively()
|
||||
when (FailureCategory.of(e)) {
|
||||
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
|
||||
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
|
||||
FailureCategory.OTHER ->
|
||||
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,142 @@
|
||||
package de.medapp.wunddoku.signature
|
||||
|
||||
import de.medapp.wunddoku.media.SafePaths
|
||||
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 =
|
||||
SafePaths.resolveChild(rootDir, documentationId)
|
||||
|
||||
private fun ensureDocumentationDir(documentationId: String): File =
|
||||
documentationDirPath(documentationId).apply { mkdirs() }
|
||||
|
||||
override fun resolve(documentationId: String, fileName: String): File =
|
||||
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
|
||||
|
||||
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val fileName = "${UUID.randomUUID()}.png"
|
||||
val targetDir = ensureDocumentationDir(documentationId)
|
||||
val targetFile = File(targetDir, fileName)
|
||||
try {
|
||||
val written = FileOutputStream(targetFile).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)
|
||||
}
|
||||
// compress() meldet Schreibfehler (z. B. Speicher voll) nur über `false`.
|
||||
if (!written) throw java.io.IOException("Signatur konnte nicht gespeichert werden.")
|
||||
} catch (e: Throwable) {
|
||||
// Keine leere/halbe PNG-Datei als scheinbar gültige Signatur zurücklassen.
|
||||
targetFile.delete()
|
||||
throw e
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
+659
-41
@@ -1,5 +1,6 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -7,24 +8,34 @@ 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.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.PhotoCamera
|
||||
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
|
||||
@@ -34,9 +45,16 @@ 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.OutlinedIconButton
|
||||
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
|
||||
@@ -45,16 +63,22 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -62,8 +86,20 @@ 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
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
||||
@@ -85,6 +121,13 @@ 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, ...). KEINE Garantie bei hartem
|
||||
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
|
||||
@@ -109,15 +152,124 @@ fun DocumentationEditScreen(
|
||||
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() }
|
||||
|
||||
// Kamerabutton im Fotobereich (Änderungswunsch): nimmt mehrere Fotos hintereinander auf.
|
||||
// Es wird die System-Kamera-App über ACTION_IMAGE_CAPTURE (TakePicture-Contract) genutzt -
|
||||
// dafür ist KEINE CAMERA-Berechtigung nötig. Nach jeder erfolgreichen Aufnahme öffnet sich
|
||||
// die Kamera automatisch erneut; Abbrechen/Zurück beendet die Serie. Die Aufnahme landet
|
||||
// zunächst in einer temporären Cache-Datei (FileProvider-Pfad "camera/") und wird dann wie
|
||||
// eine Picker-Auswahl in die Dokumentation importiert (Downsampling, EXIF, interne Ablage).
|
||||
// Der Pfad der gerade laufenden Aufnahme ist rememberSaveable, damit er einen Wechsel der
|
||||
// Konfiguration (Drehen) oder Prozesstod während der Kamera-App übersteht.
|
||||
var pendingCameraFilePath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var cameraRelaunchRequested by remember { mutableStateOf(false) }
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture(),
|
||||
) { success: Boolean ->
|
||||
val file = pendingCameraFilePath?.let(::File)
|
||||
pendingCameraFilePath = null
|
||||
if (file != null && success && file.length() > 0L) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
viewModel.onCameraPhotoCaptured(uri) { file.delete() }
|
||||
// Nächste Aufnahme der Serie.
|
||||
cameraRelaunchRequested = true
|
||||
} else {
|
||||
// Abgebrochen (oder leere Datei): Serie beendet, temporäre Datei aufräumen.
|
||||
file?.delete()
|
||||
}
|
||||
}
|
||||
fun startCamera() {
|
||||
val captureDir = File(context.cacheDir, "camera").apply { mkdirs() }
|
||||
val file = File.createTempFile("capture_", ".jpg", captureDir)
|
||||
pendingCameraFilePath = file.absolutePath
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
try {
|
||||
cameraLauncher.launch(uri)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
pendingCameraFilePath = null
|
||||
file.delete()
|
||||
viewModel.onCameraUnavailable()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(cameraRelaunchRequested) {
|
||||
if (cameraRelaunchRequested) {
|
||||
cameraRelaunchRequested = false
|
||||
startCamera()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(uiState.pdfExportMessage) {
|
||||
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()
|
||||
}
|
||||
// Speicherfehler beim Autosave/Löschen: der Nutzer muss erfahren, dass eine Änderung NICHT
|
||||
// gespeichert wurde (sonst entsteht unbemerkter Datenverlust).
|
||||
LaunchedEffect(uiState.saveError) {
|
||||
val message = uiState.saveError ?: return@LaunchedEffect
|
||||
snackbarHostState.showSnackbar(message)
|
||||
viewModel.dismissSaveError()
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -126,9 +278,27 @@ fun DocumentationEditScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// PDF-Export/Unterschrift folgen in einer späteren Phase. "Fertig" beendet
|
||||
// die Bearbeitung 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") }
|
||||
},
|
||||
)
|
||||
@@ -147,22 +317,20 @@ 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 {
|
||||
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,
|
||||
@@ -171,8 +339,13 @@ fun DocumentationEditScreen(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
onTakePhotosClick = ::startCamera,
|
||||
onDeletePhotoRequested = viewModel::requestDeletePhoto,
|
||||
loadThumbnail = viewModel::loadPhotoThumbnail,
|
||||
onPhotoClick = { viewedPhoto = it },
|
||||
onAddSignatureClick = viewModel::requestAddSignature,
|
||||
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
|
||||
loadSignatureBitmap = viewModel::loadSignatureBitmap,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -197,64 +370,216 @@ fun DocumentationEditScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: aktueller Block (Titel, Optionen,
|
||||
* Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als
|
||||
* 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 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,
|
||||
onTakePhotosClick: () -> 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?,
|
||||
) {
|
||||
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),
|
||||
) {
|
||||
item(key = "block_title") {
|
||||
Text(block.title, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
|
||||
items(block.items, key = { it.checkItemStateId }) { item ->
|
||||
if (item.isFreeText) {
|
||||
FreeTextCheckItemRow(
|
||||
item = item,
|
||||
items(blocks, key = { it.blockStateId }) { block ->
|
||||
BlockAccordionItem(
|
||||
block = block,
|
||||
expanded = block.blockStateId == expandedBlockId,
|
||||
onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
|
||||
onCheckedChanged = onCheckedChanged,
|
||||
onFreeTextChanged = onFreeTextChanged,
|
||||
)
|
||||
} else {
|
||||
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "block_navigation") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
|
||||
Text(stringResource(R.string.documentation_edit_previous))
|
||||
}
|
||||
Button(onClick = onNext, enabled = canGoNext) {
|
||||
Text(stringResource(R.string.documentation_edit_next))
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +596,10 @@ private fun DocumentationEditContent(
|
||||
if (photoImportInProgress) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
// Kamera: mehrere Aufnahmen hintereinander (s. startCamera).
|
||||
OutlinedIconButton(onClick = onTakePhotosClick) {
|
||||
Icon(Icons.Filled.PhotoCamera, contentDescription = "Fotos aufnehmen")
|
||||
}
|
||||
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
@@ -308,7 +637,12 @@ private fun DocumentationEditContent(
|
||||
}
|
||||
} else {
|
||||
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
|
||||
PhotoRow(photos = row, loadThumbnail = loadThumbnail, onDeleteRequested = onDeletePhotoRequested)
|
||||
PhotoRow(
|
||||
photos = row,
|
||||
loadThumbnail = loadThumbnail,
|
||||
onDeleteRequested = onDeletePhotoRequested,
|
||||
onPhotoClick = onPhotoClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,6 +655,7 @@ 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 ->
|
||||
@@ -328,6 +663,7 @@ private fun PhotoRow(
|
||||
photo = photo,
|
||||
loadThumbnail = loadThumbnail,
|
||||
onDeleteRequested = { onDeleteRequested(photo.id) },
|
||||
onClick = { onPhotoClick(photo) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -340,12 +676,17 @@ private fun PhotoRow(
|
||||
* (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) }
|
||||
@@ -360,7 +701,8 @@ private fun PhotoThumbnail(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(enabled = bitmap != null, onClick = onClick),
|
||||
) {
|
||||
val currentBitmap = bitmap
|
||||
when {
|
||||
@@ -396,6 +738,150 @@ private fun PhotoThumbnail(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
item.isFreeText -> FreeTextCheckItemRow(
|
||||
item = item,
|
||||
onCheckedChanged = onCheckedChanged,
|
||||
onFreeTextChanged = onFreeTextChanged,
|
||||
)
|
||||
else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckItemRow(
|
||||
item: CheckItem,
|
||||
@@ -439,3 +925,135 @@ 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) }
|
||||
|
||||
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"): Felder nur bei angehakter
|
||||
// Checkbox aktiv, das Häkchen setzt allein der Nutzer. Sonst bisheriges Verhalten.
|
||||
val ownCheckbox = spec.requiresOwnCheckbox
|
||||
val fieldsEnabled = !ownCheckbox || item.checked
|
||||
|
||||
fun updateValue(fieldIndex: Int, newValue: String) {
|
||||
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
|
||||
val encoded = spec.encodeValues(newValues)
|
||||
onFreeTextChanged(item.checkItemStateId, encoded)
|
||||
if (!ownCheckbox) {
|
||||
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
if (ownCheckbox) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
|
||||
)
|
||||
Text(item.label)
|
||||
}
|
||||
} else {
|
||||
Text(item.label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
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,
|
||||
enabled = fieldsEnabled,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
is InputFieldDef.Choice -> ChoiceField(
|
||||
field = field,
|
||||
selected = values.getOrElse(index) { "" },
|
||||
enabled = fieldsEnabled,
|
||||
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,
|
||||
enabled: Boolean = true,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(onClick = { expanded = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(selected.ifBlank { field.label }, maxLines = 1)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
field.options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
onSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+452
-25
@@ -1,5 +1,6 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
@@ -8,9 +9,17 @@ 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.error.FailureCategory
|
||||
import de.medapp.wunddoku.domain.error.runCatchingCancellable
|
||||
import de.medapp.wunddoku.domain.model.Documentation
|
||||
import de.medapp.wunddoku.domain.model.WoundCarer
|
||||
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.CancellationException
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -19,10 +28,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
data class DocumentationEditUiState(
|
||||
val documentation: Documentation? = null,
|
||||
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). */
|
||||
@@ -32,8 +49,62 @@ data class DocumentationEditUiState(
|
||||
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,
|
||||
/** Einmalige Fehlermeldung, wenn eine Änderung (Haken, Freitext, Löschen) NICHT in der
|
||||
* Datenbank gespeichert werden konnte - z. B. bei vollem Speicher. Die Anzeige zeigt dann
|
||||
* wieder den tatsächlich gespeicherten Stand, nie einen nur scheinbar gespeicherten. */
|
||||
val saveError: String? = 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +123,13 @@ data class DocumentationEditUiState(
|
||||
class DocumentationEditViewModel(
|
||||
private val documentationId: String,
|
||||
private val repository: DocumentationRepository,
|
||||
private val woundCarerRepository: WoundCarerRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Serialisiert Fotoimporte (s. [importPhotos]) und zählt laufende Importe für den Ladeindikator. */
|
||||
private val photoImportMutex = Mutex()
|
||||
private val pendingPhotoImports = AtomicInteger(0)
|
||||
|
||||
private val _uiState = MutableStateFlow(DocumentationEditUiState())
|
||||
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
|
||||
|
||||
@@ -70,7 +146,14 @@ class DocumentationEditViewModel(
|
||||
|
||||
private suspend fun loadDocumentation() {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
val documentation = repository.getFullDocumentation(documentationId)
|
||||
val documentation = try {
|
||||
repository.getFullDocumentation(documentationId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "Dokumentation konnte nicht geladen werden.") }
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
if (documentation != null) {
|
||||
it.copy(documentation = documentation, isLoading = false)
|
||||
@@ -90,11 +173,48 @@ class DocumentationEditViewModel(
|
||||
}
|
||||
|
||||
private suspend fun persistFreeText(checkItemStateId: String, text: String) {
|
||||
repository.updateFreeText(checkItemStateId, text)
|
||||
// Fehler (z. B. voller Speicher) dürfen weder die Debounce-Schleife beenden noch die App
|
||||
// abstürzen lassen. Der Wert bleibt in [pendingFreeText] und wird beim nächsten Flush
|
||||
// (ON_STOP) bzw. bei der nächsten Eingabe erneut geschrieben - kein stiller Datenverlust.
|
||||
val result = runCatchingCancellable { repository.updateFreeText(checkItemStateId, text) }
|
||||
if (result.isFailure) {
|
||||
reportSaveFailure(result.exceptionOrNull())
|
||||
return
|
||||
}
|
||||
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
|
||||
if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId)
|
||||
}
|
||||
|
||||
private fun reportSaveFailure(cause: Throwable?, fallback: String = "Die Änderung konnte nicht gespeichert werden.") {
|
||||
val message = when (cause?.let { FailureCategory.of(it) }) {
|
||||
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Änderung konnte nicht gespeichert werden."
|
||||
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Änderung konnte nicht gespeichert werden."
|
||||
else -> fallback
|
||||
}
|
||||
_uiState.update { it.copy(saveError = message) }
|
||||
}
|
||||
|
||||
/** Lädt den tatsächlich gespeicherten Stand neu (best effort), z. B. nach einem
|
||||
* fehlgeschlagenen Schreibvorgang, damit die Anzeige nie vom Datenbankstand abweicht. */
|
||||
private suspend fun refreshFromDatabase() {
|
||||
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
|
||||
if (refreshed != null) {
|
||||
// Noch nicht gespeicherte Freitexte (pendingFreeText) bleiben in der Anzeige erhalten.
|
||||
val merged = if (pendingFreeText.isEmpty()) refreshed else refreshed.copy(
|
||||
blocks = refreshed.blocks.map { block ->
|
||||
block.copy(items = block.items.map { item ->
|
||||
pendingFreeText[item.checkItemStateId]?.let { item.copy(freeTextValue = it) } ?: item
|
||||
})
|
||||
},
|
||||
)
|
||||
_uiState.update { it.copy(documentation = merged) }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSaveError() {
|
||||
_uiState.update { it.copy(saveError = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText =
|
||||
* true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine
|
||||
@@ -132,20 +252,26 @@ class DocumentationEditViewModel(
|
||||
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
||||
}
|
||||
// 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE)
|
||||
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
|
||||
viewModelScope.launch {
|
||||
val result = runCatchingCancellable { repository.setChecked(checkItemStateId, checked) }
|
||||
if (result.isFailure) {
|
||||
reportSaveFailure(result.exceptionOrNull())
|
||||
refreshFromDatabase()
|
||||
}
|
||||
|
||||
fun onNextBlock() {
|
||||
_uiState.update { state ->
|
||||
val lastIndex = state.blockCount - 1
|
||||
if (lastIndex < 0) return@update state
|
||||
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
|
||||
}
|
||||
}
|
||||
|
||||
fun onPreviousBlock() {
|
||||
/**
|
||||
* 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 ->
|
||||
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
|
||||
val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
|
||||
state.copy(expandedBlockId = newExpandedId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,36 +287,88 @@ class DocumentationEditViewModel(
|
||||
* UI konsistent den tatsächlichen Datenbankstand zeigt.
|
||||
*/
|
||||
fun onPhotosPicked(uris: List<Uri>) {
|
||||
if (uris.isEmpty()) return
|
||||
importPhotos(uris, cleanup = {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt ein mit der Kamera aufgenommenes Foto (Kamerabutton im Fotobereich, s.
|
||||
* DocumentationEditScreen). Läuft über denselben Importweg wie die Photo-Picker-Auswahl
|
||||
* (Downsampling, EXIF-Korrektur, interne Ablage). [cleanup] löscht anschließend die
|
||||
* temporäre Aufnahmedatei im Cache - unabhängig davon, ob der Import gelungen ist.
|
||||
*/
|
||||
fun onCameraPhotoCaptured(uri: Uri, cleanup: () -> Unit) {
|
||||
importPhotos(listOf(uri), cleanup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Importweg für Picker-Auswahl und Kameraaufnahmen. Importe laufen über einen
|
||||
* [photoImportMutex] strikt nacheinander: Bei einer Kameraserie kann die nächste Aufnahme
|
||||
* eintreffen, während die vorige noch importiert wird - ohne Sperre könnten zwei Importe
|
||||
* dieselbe nächste orderIndex vergeben.
|
||||
*/
|
||||
private fun importPhotos(uris: List<Uri>, cleanup: () -> Unit) {
|
||||
if (uris.isEmpty()) {
|
||||
runCatching { cleanup() }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
|
||||
pendingPhotoImports.incrementAndGet()
|
||||
try {
|
||||
photoImportMutex.withLock {
|
||||
var failureCount = 0
|
||||
var outOfMemoryCount = 0
|
||||
var outOfStorageCount = 0
|
||||
for (uri in uris) {
|
||||
try {
|
||||
repository.addPhoto(documentationId, uri)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Throwable (statt Exception): OutOfMemoryError beim Dekodieren großer
|
||||
// Fotos darf die App nicht beenden - das Bild gilt als fehlgeschlagen,
|
||||
// die übrigen Bilder werden weiter importiert.
|
||||
failureCount++
|
||||
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
|
||||
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
||||
if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++
|
||||
if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++
|
||||
// Datenschutz: Es wird bewusst NUR der Exception-Typ geloggt. Die
|
||||
// Exception-Nachricht (e.message) enthält bei Dateifehlern häufig den
|
||||
// Dateipfad bzw. die Bild-Uri und darf deshalb nie in Logs landen.
|
||||
android.util.Log.w(
|
||||
"DocumentationEdit",
|
||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
|
||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
val refreshed = repository.getFullDocumentation(documentationId)
|
||||
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
|
||||
val reason = when {
|
||||
outOfStorageCount > 0 -> " Nicht genügend Speicherplatz."
|
||||
outOfMemoryCount > 0 -> " Nicht genügend Arbeitsspeicher."
|
||||
else -> ""
|
||||
}
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
documentation = refreshed ?: state.documentation,
|
||||
photoImportInProgress = false,
|
||||
photoImportError = if (failureCount > 0) {
|
||||
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
|
||||
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden.$reason"
|
||||
} else {
|
||||
null
|
||||
state.photoImportError
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runCatching { cleanup() }
|
||||
if (pendingPhotoImports.decrementAndGet() == 0) {
|
||||
_uiState.update { it.copy(photoImportInProgress = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Meldet, dass die Kamera auf dem Gerät nicht gestartet werden konnte (keine Kamera-App). */
|
||||
fun onCameraUnavailable() {
|
||||
_uiState.update { it.copy(photoImportError = "Es konnte keine Kamera-App gestartet werden.") }
|
||||
}
|
||||
|
||||
fun dismissPhotoImportError() {
|
||||
@@ -210,8 +388,9 @@ class DocumentationEditViewModel(
|
||||
fun confirmDeletePhoto() {
|
||||
val photoId = _uiState.value.pendingDeletePhotoId ?: return
|
||||
viewModelScope.launch {
|
||||
repository.removePhoto(photoId)
|
||||
val refreshed = repository.getFullDocumentation(documentationId)
|
||||
val result = runCatchingCancellable { repository.removePhoto(photoId) }
|
||||
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Das Foto konnte nicht gelöscht werden.")
|
||||
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
documentation = refreshed ?: state.documentation,
|
||||
@@ -221,6 +400,229 @@ class DocumentationEditViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Ö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.") }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Speicherfehler o. Ä.: Dialog bleibt offen, die gezeichnete Signatur geht nicht
|
||||
// verloren und kann nach Freigabe von Speicher erneut gespeichert werden.
|
||||
val message = when (FailureCategory.of(e)) {
|
||||
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Signatur konnte nicht gespeichert werden."
|
||||
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Signatur konnte nicht gespeichert werden."
|
||||
FailureCategory.OTHER -> "Die Signatur konnte nicht gespeichert werden."
|
||||
}
|
||||
_uiState.update { it.copy(signatureError = message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val result = runCatchingCancellable { repository.removeSignature(signatureId) }
|
||||
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Die Signatur konnte nicht gelöscht werden.")
|
||||
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
|
||||
_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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
|
||||
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
|
||||
@@ -233,6 +635,29 @@ class DocumentationEditViewModel(
|
||||
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
|
||||
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
|
||||
@@ -241,9 +666,10 @@ class DocumentationEditViewModel(
|
||||
fun flushPendingChanges() {
|
||||
if (pendingFreeText.isEmpty()) return
|
||||
val toFlush = pendingFreeText.toMap()
|
||||
pendingFreeText.clear()
|
||||
viewModelScope.launch {
|
||||
toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) }
|
||||
// Einträge werden erst NACH erfolgreichem Schreiben aus pendingFreeText entfernt -
|
||||
// scheitert das Schreiben, bleibt der Text für den nächsten Versuch erhalten.
|
||||
toFlush.forEach { (checkItemStateId, text) -> persistFreeText(checkItemStateId, text) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +687,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+235
@@ -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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import androidx.navigation.toRoute
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||
import de.medapp.wunddoku.domain.model.Patient
|
||||
import de.medapp.wunddoku.domain.model.PatientListItem
|
||||
import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS
|
||||
@@ -18,6 +19,7 @@ import de.medapp.wunddoku.ui.components.format.filterDigits
|
||||
import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull
|
||||
import de.medapp.wunddoku.ui.components.format.toGermanDateDigits
|
||||
import de.medapp.wunddoku.ui.navigation.Screen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -187,7 +189,7 @@ class PatientFormViewModel(
|
||||
fun useExistingPatient() {
|
||||
val existing = _uiState.value.duplicateWarning ?: return
|
||||
_uiState.update { it.copy(duplicateWarning = null) }
|
||||
viewModelScope.launch { finish(existing.id) }
|
||||
viewModelScope.launch { guardedFinish(existing.id) }
|
||||
}
|
||||
|
||||
/** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */
|
||||
@@ -241,10 +243,37 @@ class PatientFormViewModel(
|
||||
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
val saved = repository.upsert(patient)
|
||||
_uiState.update { it.copy(validationError = null) }
|
||||
finish(saved.id)
|
||||
val saved = try {
|
||||
repository.upsert(patient)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
reportPersistenceFailure(e, "Der Patient konnte nicht gespeichert werden.")
|
||||
return@launch
|
||||
}
|
||||
_uiState.update { it.copy(validationError = null) }
|
||||
guardedFinish(saved.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wie [finish], fängt aber Datenbankfehler (z. B. voller Speicher) ab: Das Formular bleibt
|
||||
* mit allen Eingaben offen und zeigt eine Meldung, statt dass die App abstürzt. */
|
||||
private suspend fun guardedFinish(resolvedPatientId: String) {
|
||||
try {
|
||||
finish(resolvedPatientId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
reportPersistenceFailure(e, "Die Dokumentation konnte nicht angelegt werden.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportPersistenceFailure(cause: Throwable, fallback: String) {
|
||||
val message = when (FailureCategory.of(cause)) {
|
||||
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - bitte Speicher freigeben und erneut versuchen."
|
||||
else -> fallback
|
||||
}
|
||||
_uiState.update { it.copy(validationError = message) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,17 +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,12 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
|
||||
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
|
||||
für die Freigabe über den FileProvider frei - und zwar NUR das, was die App tatsächlich
|
||||
nach außen gibt (PDF-Export, Seitenbilder, Kameraaufnahme). Die Ordner photos/ und
|
||||
signatures/ (Originalfotos, Unterschriften) sind bewusst NICHT freigegeben (Prinzip der
|
||||
minimalen Freigabe; Datenschutz-Review QA-006). Es gibt bewusst keinen <external-path>-
|
||||
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
|
||||
intern (Abschnitt "Storage-Strategie" der Architektur, v3).
|
||||
intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
|
||||
-->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path name="pdf_exports" path="pdf/" />
|
||||
<files-path name="photos" path="photos/" />
|
||||
<files-path name="signatures" path="signatures/" />
|
||||
<files-path name="pdf_images" path="pdf_images/" />
|
||||
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
|
||||
Dokumentation, danach sofort gelöscht. -->
|
||||
<cache-path name="camera_captures" path="camera/" />
|
||||
</paths>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.medapp.wunddoku.domain.error
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Reine JVM-Tests für die Fehlereinordnung (Speichermangel erkennen) und
|
||||
* [runCatchingCancellable].
|
||||
*/
|
||||
class FailureCategoryTest {
|
||||
|
||||
// --- Arbeitsspeicher -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun out_of_memory_error_wird_erkannt() {
|
||||
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(OutOfMemoryError("Failed to allocate")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun out_of_memory_error_wird_auch_als_ursache_erkannt() {
|
||||
val wrapped = RuntimeException("Export fehlgeschlagen", OutOfMemoryError())
|
||||
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(wrapped))
|
||||
}
|
||||
|
||||
// --- Speicherplatz -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun enospc_ioexception_wird_als_speicher_voll_erkannt() {
|
||||
val e = IOException("write failed: ENOSPC (No space left on device)")
|
||||
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sqlite_disk_full_meldung_wird_als_speicher_voll_erkannt() {
|
||||
val e = RuntimeException("database or disk is full (code 13 SQLITE_FULL)")
|
||||
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun erkennung_ist_unabhaengig_von_gross_und_kleinschreibung() {
|
||||
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(IOException("NO SPACE LEFT ON DEVICE")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speicher_voll_in_tiefer_ursachenkette_wird_erkannt() {
|
||||
val root = IOException("No space left on device")
|
||||
val chain = IllegalStateException("a", RuntimeException("b", RuntimeException("c", root)))
|
||||
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(chain))
|
||||
}
|
||||
|
||||
// --- Sonstige Fehler -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun gewoehnliche_fehler_sind_other() {
|
||||
assertEquals(FailureCategory.OTHER, FailureCategory.of(FileNotFoundException("weg")))
|
||||
assertEquals(FailureCategory.OTHER, FailureCategory.of(IllegalStateException()))
|
||||
assertEquals(FailureCategory.OTHER, FailureCategory.of(RuntimeException(null as String?)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zyklische_ursachenkette_fuehrt_nicht_zur_endlosschleife() {
|
||||
val a = RuntimeException("a")
|
||||
val b = RuntimeException("b", a)
|
||||
a.initCause(b)
|
||||
assertEquals(FailureCategory.OTHER, FailureCategory.of(a))
|
||||
}
|
||||
|
||||
// --- runCatchingCancellable ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun runCatchingCancellable_liefert_erfolg_und_fehler_als_result() {
|
||||
assertEquals(42, runCatchingCancellable { 42 }.getOrNull())
|
||||
val failure = runCatchingCancellable<Int> { throw IOException("x") }
|
||||
assertTrue(failure.exceptionOrNull() is IOException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runCatchingCancellable_reicht_coroutine_abbruch_weiter() {
|
||||
try {
|
||||
runCatchingCancellable<Unit> { throw CancellationException("abgebrochen") }
|
||||
fail("CancellationException darf nicht verschluckt werden")
|
||||
} catch (expected: CancellationException) {
|
||||
assertEquals("abgebrochen", expected.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,80 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Reine JVM-Tests für [ImageDownsampler.computeInSampleSize] - der Kern der speicherschonenden
|
||||
* Bilddekodierung (Schutz vor OutOfMemoryError bei großen Kamerafotos) und der Robustheit
|
||||
* gegenüber ungültigen Abmessungen defekter Bilddateien.
|
||||
*/
|
||||
class ImageDownsamplerTest {
|
||||
|
||||
private val target = ImageDownsampler.MAX_STORED_DIMENSION_PX
|
||||
|
||||
// --- ungültige / defekte Abmessungen -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun ungueltige_abmessungen_defekter_bilder_liefern_1_statt_zu_werfen() {
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 0, target))
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(-1, -1, target))
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 0, target))
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 4000, target))
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, 0))
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, -5))
|
||||
}
|
||||
|
||||
// --- Bilder, die nicht verkleinert werden müssen -------------------------------------
|
||||
|
||||
@Test
|
||||
fun kleine_bilder_werden_nicht_heruntergerechnet() {
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(100, 100, target))
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(target, target, target))
|
||||
assertEquals(1, ImageDownsampler.computeInSampleSize(1, 1, target))
|
||||
}
|
||||
|
||||
// --- Große Bilder (Speichermangel vermeiden) -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun grosse_kamerafotos_werden_deutlich_heruntergerechnet() {
|
||||
// 48 MP ≈ 8000 x 6000
|
||||
val sample = ImageDownsampler.computeInSampleSize(8000, 6000, target)
|
||||
assertTrue("erwartet >= 2, war $sample", sample >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ergebnis_ist_immer_eine_zweierpotenz_und_haelt_die_zielgroesse_ein() {
|
||||
val sizes = listOf(1, 2, 99, 100, 1999, 2000, 2001, 3999, 4000, 4001, 6000, 8000, 12000, 20000, 65535)
|
||||
for (width in sizes) {
|
||||
for (height in sizes) {
|
||||
val sample = ImageDownsampler.computeInSampleSize(width, height, target)
|
||||
val largest = maxOf(width, height)
|
||||
|
||||
assertTrue("Zweierpotenz erwartet bei ${width}x$height, war $sample", sample >= 1 && sample and (sample - 1) == 0)
|
||||
if (largest > target) {
|
||||
// Nie unter die Zielgröße dekodieren ...
|
||||
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample >= target)
|
||||
// ... aber auch nie mehr als doppelt so groß wie nötig (kein Speicher-Verschwenden).
|
||||
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample < 2 * target)
|
||||
} else {
|
||||
assertEquals(1, sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extrem_grosse_werte_laufen_nicht_ueber() {
|
||||
val sample = ImageDownsampler.computeInSampleSize(Int.MAX_VALUE, Int.MAX_VALUE, target)
|
||||
assertTrue(sample >= 1)
|
||||
assertTrue(sample and (sample - 1) == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thumbnail_ziel_ist_kleiner_als_speicherziel() {
|
||||
val full = ImageDownsampler.computeInSampleSize(2000, 1500, target)
|
||||
val thumb = ImageDownsampler.computeInSampleSize(2000, 1500, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
|
||||
assertTrue(thumb > full)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/** Datenschutz/Sicherheit: kein Ausbrechen aus dem privaten Ordner per Path-Traversal. */
|
||||
class SafePathsTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private fun assertRejected(name: String) {
|
||||
val root = temp.newFolder()
|
||||
try {
|
||||
SafePaths.resolveChild(root, name)
|
||||
fail("'$name' hätte abgelehnt werden müssen")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normale_namen_werden_unterhalb_des_wurzelordners_aufgeloest() {
|
||||
val root = temp.newFolder()
|
||||
|
||||
val result = SafePaths.resolveChild(root, "3f2a-uuid.jpg")
|
||||
|
||||
assertEquals(File(root, "3f2a-uuid.jpg"), result)
|
||||
assertTrue(result.canonicalPath.startsWith(root.canonicalPath + File.separator))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uebliche_namen_mit_punkten_und_bindestrichen_sind_erlaubt() {
|
||||
val root = temp.newFolder()
|
||||
SafePaths.resolveChild(root, "2026-08-19_Max.Mustermann.v2.pdf")
|
||||
SafePaths.resolveChild(root, "..hidden") // beginnt mit Punkten, ist aber kein Traversal
|
||||
}
|
||||
|
||||
@Test
|
||||
fun path_traversal_wird_abgelehnt() {
|
||||
assertRejected("..")
|
||||
assertRejected("../geheim")
|
||||
assertRejected("../../etc/passwd")
|
||||
assertRejected("a/../../b")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pfadtrenner_und_leere_namen_werden_abgelehnt() {
|
||||
assertRejected("")
|
||||
assertRejected(" ")
|
||||
assertRejected(".")
|
||||
assertRejected("unter/ordner.jpg")
|
||||
assertRejected("unter\\ordner.jpg")
|
||||
assertRejected("/absolut.jpg")
|
||||
assertRejected("nullbyte\u0000.jpg")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun symbolischer_link_aus_dem_ordner_heraus_wird_abgelehnt() {
|
||||
val root = temp.newFolder()
|
||||
val outside = temp.newFolder()
|
||||
val link = File(root, "link")
|
||||
try {
|
||||
java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath())
|
||||
} catch (e: Exception) {
|
||||
return // Dateisystem ohne Symlink-Unterstützung: Test nicht anwendbar
|
||||
}
|
||||
|
||||
try {
|
||||
SafePaths.resolveChild(root, "link")
|
||||
fail("Symlink aus dem Ordner heraus hätte abgelehnt werden müssen")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// erwartet
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,106 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Reine JVM-Tests für die Fehlermeldungen des PDF-Bereichs: jede Fehlerart hat eine
|
||||
* verständliche deutsche Meldung, und technische Details (Pfade, Exception-Texte - können
|
||||
* Patientennamen enthalten) gelangen nie in die Anzeige.
|
||||
*/
|
||||
class ErrorMessagesTest {
|
||||
|
||||
private val sensitive = "/data/user/0/de.medapp.wunddoku/files/pdf/2026-08-19_Max_Mustermann_1980-03-14.pdf"
|
||||
|
||||
private val exportErrors: List<PdfExportError> = listOf(
|
||||
PdfExportError.DocumentationNotFound,
|
||||
PdfExportError.DirectoryCreationFailed(sensitive),
|
||||
PdfExportError.WriteFailed(sensitive),
|
||||
PdfExportError.Unexpected(sensitive),
|
||||
PdfExportError.OutOfMemory,
|
||||
PdfExportError.InsufficientStorage,
|
||||
)
|
||||
|
||||
private val openFailures: List<PdfOpenResult.Failure> = listOf(
|
||||
PdfOpenResult.Failure.FileNotFound,
|
||||
PdfOpenResult.Failure.UriResolutionFailed(sensitive),
|
||||
PdfOpenResult.Failure.NoViewerAvailable,
|
||||
PdfOpenResult.Failure.Unexpected(sensitive),
|
||||
)
|
||||
|
||||
private val shareFailures: List<PdfShareResult.Failure> = listOf(
|
||||
PdfShareResult.Failure.UriResolutionFailed(sensitive),
|
||||
PdfShareResult.Failure.NoAppAvailable,
|
||||
PdfShareResult.Failure.Unexpected(sensitive),
|
||||
)
|
||||
|
||||
private val imageShareFailures: List<PdfImageShareResult.Failure> = listOf(
|
||||
PdfImageShareResult.Failure.NoImages,
|
||||
PdfImageShareResult.Failure.UriResolutionFailed(sensitive),
|
||||
PdfImageShareResult.Failure.NoAppAvailable,
|
||||
PdfImageShareResult.Failure.Unexpected(sensitive),
|
||||
)
|
||||
|
||||
private val imageConversionFailures: List<PdfToImagesResult.Failure> = listOf(
|
||||
PdfToImagesResult.Failure.FileNotFound,
|
||||
PdfToImagesResult.Failure.EmptyDocument,
|
||||
PdfToImagesResult.Failure.Unexpected(sensitive),
|
||||
PdfToImagesResult.Failure.OutOfMemory,
|
||||
PdfToImagesResult.Failure.InsufficientStorage,
|
||||
)
|
||||
|
||||
private fun assertSafe(message: String) {
|
||||
assertTrue("Meldung darf nicht leer sein", message.isNotBlank())
|
||||
assertFalse("Meldung enthält Pfad/Patientendaten: $message", message.contains("Mustermann"))
|
||||
assertFalse("Meldung enthält Pfad: $message", message.contains("/data/"))
|
||||
assertFalse("Meldung enthält Dateiendung/Pfad: $message", message.contains(".pdf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alle_pdf_export_fehler_haben_eine_sichere_meldung() {
|
||||
exportErrors.forEach { assertSafe(it.toUserMessage()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alle_oeffnen_fehler_haben_eine_sichere_meldung() {
|
||||
openFailures.forEach { assertSafe(it.toUserMessage()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alle_pdf_teilen_fehler_haben_eine_sichere_meldung() {
|
||||
shareFailures.forEach { assertSafe(it.toUserMessage()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alle_bilder_teilen_fehler_haben_eine_sichere_meldung() {
|
||||
imageShareFailures.forEach { assertSafe(it.toUserMessage()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alle_bildkonvertierungs_fehler_haben_eine_sichere_meldung() {
|
||||
imageConversionFailures.forEach { assertSafe(it.toUserMessage()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun speichermangel_hat_eine_eigene_aussagekraeftige_meldung() {
|
||||
assertNotEquals(PdfExportError.OutOfMemory.toUserMessage(), PdfExportError.InsufficientStorage.toUserMessage())
|
||||
assertNotEquals(
|
||||
PdfExportError.OutOfMemory.toUserMessage(),
|
||||
PdfExportError.Unexpected("x").toUserMessage(),
|
||||
)
|
||||
assertNotEquals(
|
||||
PdfToImagesResult.Failure.OutOfMemory.toUserMessage(),
|
||||
PdfToImagesResult.Failure.InsufficientStorage.toUserMessage(),
|
||||
)
|
||||
assertTrue(PdfExportError.InsufficientStorage.toUserMessage().contains("Speicher"))
|
||||
assertTrue(PdfExportError.OutOfMemory.toUserMessage().contains("Arbeitsspeicher"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unterschiedliche_fehlerarten_erhalten_unterschiedliche_meldungen() {
|
||||
val messages = exportErrors.map { it.toUserMessage() }
|
||||
assertTrue(messages.toSet().size == messages.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Datenschutz-Löschkonzept: PDF-/Bildexporte (enthalten Patientendaten) und liegengebliebene
|
||||
* Kameraaufnahmen dürfen nicht dauerhaft im Speicher bleiben.
|
||||
*/
|
||||
class ExportedFilesCleanerTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private lateinit var filesDir: File
|
||||
private lateinit var cacheDir: File
|
||||
private val day = 24L * 60 * 60 * 1000
|
||||
private val now = 1_800_000_000_000L // fester "Jetzt"-Zeitpunkt
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
filesDir = temp.newFolder("files")
|
||||
cacheDir = temp.newFolder("cache")
|
||||
}
|
||||
|
||||
private fun cleaner() = InternalExportedFilesCleaner(filesDir, cacheDir, now = { now })
|
||||
|
||||
private fun file(path: File, ageMillis: Long = 0): File {
|
||||
path.parentFile!!.mkdirs()
|
||||
path.writeText("x")
|
||||
path.setLastModified(now - ageMillis)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun pdf(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf/$docId/bericht.pdf"), ageMillis)
|
||||
private fun image(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf_images/$docId/seite_01.jpg"), ageMillis)
|
||||
private fun capture(name: String, ageMillis: Long) = file(File(cacheDir, "camera/$name"), ageMillis)
|
||||
|
||||
// --- Löschen mit der Dokumentation ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_entfernt_pdf_und_bildexporte_der_dokumentation() = runBlocking {
|
||||
val p = pdf("doc-1")
|
||||
val i = image("doc-1")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertFalse(p.exists())
|
||||
assertFalse(i.exists())
|
||||
assertFalse(File(filesDir, "pdf/doc-1").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_laesst_andere_dokumentationen_unberuehrt() = runBlocking {
|
||||
val other = pdf("doc-2")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertTrue(other.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
|
||||
cleaner().deleteExportsFor("gibt-es-nicht")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_loescht_nie_ausserhalb_der_exportordner() = runBlocking {
|
||||
val secret = file(File(filesDir, "secret/patientendaten.db"))
|
||||
val photos = file(File(filesDir, "photos/doc-1/foto.jpg"))
|
||||
File(filesDir, "pdf").mkdirs()
|
||||
|
||||
cleaner().deleteExportsFor("../secret")
|
||||
cleaner().deleteExportsFor("..")
|
||||
cleaner().deleteExportsFor("../photos/doc-1")
|
||||
|
||||
assertTrue(secret.exists())
|
||||
assertTrue(photos.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExportsFor_beruehrt_fotos_und_signaturen_der_dokumentation_nicht() = runBlocking {
|
||||
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"))
|
||||
val signature = file(File(filesDir, "signatures/doc-1/sig.png"))
|
||||
pdf("doc-1")
|
||||
|
||||
cleaner().deleteExportsFor("doc-1")
|
||||
|
||||
assertTrue("Löschen der Exporte ist nicht Löschen der Dokumentation", photo.exists() && signature.exists())
|
||||
}
|
||||
|
||||
// --- Aufbewahrungsfristen ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_exporte_aelter_als_7_tage_und_behaelt_frische() = runBlocking {
|
||||
val old = pdf("doc-old", ageMillis = 8 * day)
|
||||
val oldImage = image("doc-old", ageMillis = 8 * day)
|
||||
val fresh = pdf("doc-fresh", ageMillis = 1 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse(old.exists())
|
||||
assertFalse(oldImage.exists())
|
||||
assertTrue(fresh.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_leere_unterordner() = runBlocking {
|
||||
pdf("doc-old", ageMillis = 30 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse(File(filesDir, "pdf/doc-old").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_entfernt_liegengebliebene_kameraaufnahmen_nach_24_stunden() = runBlocking {
|
||||
val stale = capture("capture_old.jpg", ageMillis = day + 60_000)
|
||||
val recent = capture("capture_new.jpg", ageMillis = 2 * 60 * 60 * 1000)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertFalse("Wundfoto darf nicht dauerhaft im Cache liegen bleiben", stale.exists())
|
||||
assertTrue("aktuelle Aufnahme wird eventuell noch importiert", recent.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_beruehrt_dokumentationsdaten_nie() = runBlocking {
|
||||
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"), ageMillis = 365 * day)
|
||||
val signature = file(File(filesDir, "signatures/doc-1/sig.png"), ageMillis = 365 * day)
|
||||
|
||||
cleaner().deleteStaleFiles()
|
||||
|
||||
assertTrue("Dokumentationsfotos/-signaturen sind keine Exporte", photo.exists() && signature.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteStaleFiles_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
|
||||
cleaner().deleteStaleFiles()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun none_cleaner_tut_nichts() = runBlocking {
|
||||
val p = pdf("doc-1", ageMillis = 100 * day)
|
||||
|
||||
ExportedFilesCleaner.None.deleteExportsFor("doc-1")
|
||||
ExportedFilesCleaner.None.deleteStaleFiles()
|
||||
|
||||
assertTrue(p.exists())
|
||||
}
|
||||
}
|
||||
@@ -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("???", "***"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.medapp.wunddoku.privacy
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/**
|
||||
* Datenschutz-Regressionstests (QA-006) auf Quelltext- und Konfigurationsebene.
|
||||
*
|
||||
* Läuft als reiner JVM-Test ohne Gerät und liest die Projektdateien direkt. Er soll verhindern,
|
||||
* dass später versehentlich etwas eingebaut wird, das die Datenschutz-Zusagen der App bricht
|
||||
* (Netzwerkzugriff, sensible Logs, öffentlicher Speicher, Backup, zu weite Freigaben).
|
||||
* Schlägt ein Test fehl, ist das ein Hinweis zum Prüfen - nicht zwingend ein Fehler: dann
|
||||
* bewusst entscheiden und die Datenschutz-Dokumentation anpassen.
|
||||
*/
|
||||
class PrivacySourceScanTest {
|
||||
|
||||
private lateinit var mainDir: File
|
||||
|
||||
@Before
|
||||
fun locateSources() {
|
||||
mainDir = listOf(File("src/main"), File("app/src/main"))
|
||||
.firstOrNull { File(it, "AndroidManifest.xml").exists() }
|
||||
?: File("")
|
||||
assumeTrue("Quellverzeichnis nicht gefunden (Arbeitsverzeichnis: ${File(".").absolutePath})", mainDir.path.isNotEmpty())
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen -----------------------------------------------------------------
|
||||
|
||||
private fun kotlinSources(): List<Pair<String, String>> =
|
||||
File(mainDir, "java").walkTopDown()
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.map { it.relativeTo(mainDir).path to stripComments(it.readText()) }
|
||||
.toList()
|
||||
|
||||
/** Entfernt Kommentare, damit Dokumentationstext keine Fehlalarme auslöst. */
|
||||
private fun stripComments(source: String): String =
|
||||
source
|
||||
.replace(Regex("/\\*[\\s\\S]*?\\*/"), "")
|
||||
.replace(Regex("//[^\\n]*"), "")
|
||||
|
||||
private fun findings(pattern: Regex, sources: List<Pair<String, String>> = kotlinSources()): List<String> =
|
||||
sources.flatMap { (path, text) ->
|
||||
pattern.findAll(text).map { "$path: ${it.value}" }.toList()
|
||||
}
|
||||
|
||||
private fun assertNone(description: String, pattern: Regex) {
|
||||
val found = findings(pattern)
|
||||
assertTrue("$description - gefunden: $found", found.isEmpty())
|
||||
}
|
||||
|
||||
private fun parseXml(relativePath: String) =
|
||||
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File(mainDir, relativePath))
|
||||
|
||||
// --- Logging -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun keine_konsolenausgaben_oder_stacktrace_ausgaben() {
|
||||
assertNone(
|
||||
"println/printStackTrace/System.out/Timber können Patientendaten in Logs schreiben",
|
||||
Regex("""\bprintln\s*\(|printStackTrace\s*\(|System\.(out|err)\b|\bTimber\b"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun log_aufrufe_enthalten_keine_nachrichten_pfade_uris_oder_personendaten() {
|
||||
val callStart = Regex("""\bLog\s*\.\s*(v|d|i|w|e|wtf)\s*\(""")
|
||||
val forbidden = Regex(
|
||||
"""(?i)\.message\b|localizedMessage|stackTraceToString|getStackTraceString|\buri\b|\bpath\b|absolutePath|fileName|patient|firstName|lastName|birthDate|signer|street|phone"""
|
||||
)
|
||||
val violations = mutableListOf<String>()
|
||||
|
||||
for ((path, text) in kotlinSources()) {
|
||||
for (match in callStart.findAll(text)) {
|
||||
val openIndex = match.range.last
|
||||
var depth = 0
|
||||
var end = openIndex
|
||||
for (i in openIndex until text.length) {
|
||||
when (text[i]) {
|
||||
'(' -> depth++
|
||||
')' -> { depth--; if (depth == 0) { end = i; break } }
|
||||
}
|
||||
}
|
||||
val call = text.substring(match.range.first, end + 1)
|
||||
if (forbidden.containsMatchIn(call)) violations += "$path: ${call.replace(Regex("\\s+"), " ")}"
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue("Log-Aufrufe mit potenziell sensiblem Inhalt: $violations", violations.isEmpty())
|
||||
}
|
||||
|
||||
// --- Speicherorte --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun kein_zugriff_auf_oeffentlichen_oder_externen_speicher() {
|
||||
assertNone(
|
||||
"Patientendaten dürfen nur im privaten internen Speicher liegen",
|
||||
Regex(
|
||||
"""getExternalFilesDir|getExternalFilesDirs|externalCacheDir|externalMediaDirs|getExternalStorage|Environment\.getExternal|MediaStore\.|WRITE_EXTERNAL_STORAGE|READ_EXTERNAL_STORAGE|MANAGE_EXTERNAL_STORAGE|MODE_WORLD_READABLE|MODE_WORLD_WRITEABLE|DIRECTORY_(PICTURES|DOWNLOADS|DOCUMENTS)"""
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_nutzung_der_zwischenablage() {
|
||||
assertNone(
|
||||
"Die Zwischenablage ist für andere Apps lesbar",
|
||||
Regex("""ClipboardManager|setPrimaryClip|ClipboardManager"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_personendaten_als_text_oder_betreff_in_share_intents() {
|
||||
assertNone(
|
||||
"Share-Intents dürfen nur die PDF/Bilder (EXTRA_STREAM), keinen Text mit Patientendaten tragen",
|
||||
Regex("""EXTRA_TEXT|EXTRA_SUBJECT|EXTRA_EMAIL|EXTRA_CC|EXTRA_BCC|EXTRA_HTML_TEXT"""),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Netzwerk ------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun kein_netzwerkcode_im_quelltext() {
|
||||
assertNone(
|
||||
"Die App arbeitet offline - kein Netzwerkzugriff",
|
||||
Regex("""HttpURLConnection|HttpsURLConnection|java\.net\.URL\b|URLConnection|\bSocket\s*\(|okhttp|retrofit|\bktor\b|WebView|firebase|crashlytics|analytics""", RegexOption.IGNORE_CASE),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keine_netzwerk_bibliotheken_in_den_abhaengigkeiten() {
|
||||
val gradle = listOf(File("app/build.gradle.kts"), File("build.gradle.kts")).firstOrNull { it.exists() }
|
||||
assumeTrue("build.gradle.kts nicht gefunden", gradle != null)
|
||||
val dependencies = gradle!!.readLines()
|
||||
.filter { it.trim().startsWith("implementation") || it.trim().startsWith("api(") }
|
||||
val forbidden = Regex("""okhttp|retrofit|\bktor\b|volley|firebase|crashlytics|analytics|sentry|coil|glide|picasso|play-services""", RegexOption.IGNORE_CASE)
|
||||
val found = dependencies.filter { forbidden.containsMatchIn(it) }
|
||||
assertTrue("Abhängigkeiten mit Netzwerk-/Tracking-Bezug: $found", found.isEmpty())
|
||||
}
|
||||
|
||||
// --- Manifest ------------------------------------------------------------------------
|
||||
|
||||
private fun manifest(): String = File(mainDir, "AndroidManifest.xml").readText()
|
||||
|
||||
@Test
|
||||
fun manifest_fordert_keine_berechtigungen_an() {
|
||||
val permissions = Regex("""<uses-permission[^>]*>""").findAll(manifest()).map { it.value }.toList()
|
||||
val requested = permissions.filterNot { it.contains("tools:node=\"remove\"") }
|
||||
assertTrue("Unnötige Berechtigungen im Manifest: $requested", requested.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifest_entfernt_die_internet_berechtigung_explizit() {
|
||||
val internet = Regex("""<uses-permission[^>]*android\.permission\.INTERNET[^>]*>""").find(manifest())
|
||||
assertTrue("INTERNET muss per tools:node=\"remove\" ausgeschlossen sein", internet != null && internet.value.contains("tools:node=\"remove\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manifest_deaktiviert_backup_und_verlangt_keine_cleartext_oder_debug_freigaben() {
|
||||
val text = manifest()
|
||||
assertTrue("allowBackup muss false sein", text.contains("""android:allowBackup="false""""))
|
||||
assertTrue("dataExtractionRules müssen gesetzt sein", text.contains("android:dataExtractionRules="))
|
||||
assertTrue(!text.contains("""android:usesCleartextTraffic="true""""))
|
||||
assertTrue(!text.contains("android:debuggable"))
|
||||
assertTrue(!text.contains("""android:allowBackup="true""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nur_die_start_activity_ist_exportiert_und_der_fileprovider_nicht() {
|
||||
val text = manifest()
|
||||
assertEquals(
|
||||
"Genau eine Komponente (MainActivity) darf exportiert sein",
|
||||
1,
|
||||
Regex("""android:exported="true"""").findAll(text).count(),
|
||||
)
|
||||
val provider = Regex("""<provider[\s\S]*?</provider>""").find(text)!!.value
|
||||
assertTrue(provider.contains("""android:exported="false""""))
|
||||
assertTrue(provider.contains("""android:grantUriPermissions="true""""))
|
||||
}
|
||||
|
||||
// --- Backup-Regeln -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun data_extraction_rules_schliessen_alle_daten_aus_cloud_backup_und_geraetetransfer_aus() {
|
||||
val doc = parseXml("res/xml/data_extraction_rules.xml")
|
||||
for (section in listOf("cloud-backup", "device-transfer")) {
|
||||
val nodes = doc.getElementsByTagName(section)
|
||||
assertEquals("<$section> muss vorhanden sein", 1, nodes.length)
|
||||
val element = nodes.item(0) as org.w3c.dom.Element
|
||||
assertEquals("<$section> darf kein <include> enthalten", 0, element.getElementsByTagName("include").length)
|
||||
val excluded = (0 until element.getElementsByTagName("exclude").length)
|
||||
.map { (element.getElementsByTagName("exclude").item(it) as org.w3c.dom.Element).getAttribute("domain") }
|
||||
.toSet()
|
||||
assertTrue("<$section> muss database, file, sharedpref ausschließen, war $excluded", excluded.containsAll(setOf("database", "file", "sharedpref")))
|
||||
}
|
||||
}
|
||||
|
||||
// --- FileProvider-Freigaben ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fileprovider_gibt_nur_export_und_kameraordner_frei() {
|
||||
val doc = parseXml("res/xml/file_paths.xml")
|
||||
val paths = doc.getElementsByTagName("paths").item(0).childNodes
|
||||
val entries = (0 until paths.length).map { paths.item(it) }
|
||||
.filterIsInstance<org.w3c.dom.Element>()
|
||||
.map { it.tagName to it.getAttribute("path") }
|
||||
|
||||
val allowed = setOf("files-path" to "pdf/", "files-path" to "pdf_images/", "cache-path" to "camera/")
|
||||
val unexpected = entries.filterNot { it in allowed }
|
||||
assertTrue(
|
||||
"Zu weite FileProvider-Freigaben (nur $allowed erlaubt): $unexpected",
|
||||
unexpected.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# VulneraDoc – Datenschutz- und Datensicherheits-Dokumentation
|
||||
|
||||
Stand: Datenschutz-Review QA-006 (Projektstand `dev-QA004` + Maßnahmen aus diesem Review)
|
||||
|
||||
> **Hinweis:** Dieses Dokument beschreibt die *technische* Umsetzung des Datenschutzes in der App
|
||||
> (Datenflüsse, Speicherorte, Schutzmaßnahmen). Es ist keine Rechtsberatung. Die rechtliche
|
||||
> Bewertung (Rechtsgrundlage, Verzeichnis der Verarbeitungstätigkeiten, ggf. Datenschutz-
|
||||
> Folgenabschätzung, Auftragsverarbeitung, Einwilligungen) muss durch die verantwortliche Stelle
|
||||
> bzw. deren Datenschutzbeauftragte erfolgen. Die Abschnitte 9 und 10 liefern dafür die
|
||||
> technischen Eingangsdaten.
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck und Einordnung
|
||||
|
||||
VulneraDoc ist eine **Offline-Android-App zur Wunddokumentation**. Pflegekräfte erfassen Patienten,
|
||||
dokumentieren Wunden (Checklisten, Freitext, Fotos, Unterschriften) und erzeugen daraus ein PDF,
|
||||
das sie öffnen oder teilen können.
|
||||
|
||||
Verarbeitet werden **Gesundheitsdaten** (Wundbefunde, Wundfotos) und weitere personenbezogene Daten.
|
||||
Gesundheitsdaten zählen nach Art. 9 DSGVO zu den besonderen Kategorien personenbezogener Daten und
|
||||
verlangen ein erhöhtes Schutzniveau.
|
||||
|
||||
**Grundprinzip der App:** Die Daten verlassen das Gerät **ausschließlich auf ausdrückliche
|
||||
Handlung des Nutzers** (PDF/Bilder öffnen oder teilen). Es gibt keinen Server, keine Cloud, kein
|
||||
Konto, keine Analyse- oder Absturzberichts-Dienste.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verarbeitete Daten
|
||||
|
||||
| Datenart | Felder / Inhalt | Speicherort (App-intern) |
|
||||
|---|---|---|
|
||||
| Patientenstammdaten | Vorname, Nachname, Geburtsdatum, Straße, PLZ, Ort, bis zu 3 Telefonnummern | Datenbank `wunddoku.db` |
|
||||
| Dokumentation | Erstelldatum, Checklisten-Zustände, Freitexte (Wundbefund, Maßnahmen) | Datenbank |
|
||||
| Wundfotos | Fotos aus Kamera bzw. Photo Picker | `files/photos/<Dokumentations-ID>/` |
|
||||
| Unterschriften | Name des Unterzeichners, Unterschrift als PNG | Datenbank + `files/signatures/<Dokumentations-ID>/` |
|
||||
| Wundversorger-Register | Name und Unterschrift der Pflegekraft (für Wiederverwendung) | Datenbank + `files/wound_carer_signatures/` |
|
||||
| PDF-Export | Vollständiger Bericht inkl. Stammdaten, Befund, Fotos, Unterschriften | `files/pdf/<Dokumentations-ID>/` |
|
||||
| Bildexport | Seiten des PDFs als JPEG (zum Teilen als Bilder) | `files/pdf_images/<Dokumentations-ID>/` |
|
||||
| Kameraaufnahme (temporär) | Foto bis zum Import in die Dokumentation | `cache/camera/` |
|
||||
|
||||
Alle Speicherorte liegen im **privaten internen App-Speicher**. Es wird kein öffentlicher
|
||||
Speicher (Galerie, Downloads, SD-Karte) beschrieben.
|
||||
|
||||
**Hinweis Dateiname:** Das PDF heißt `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf` und enthält
|
||||
damit personenbezogene Daten. Der Name wird beim Teilen/Öffnen an die gewählte Ziel-App übergeben
|
||||
(siehe Abschnitt 6, Restrisiko R4).
|
||||
|
||||
---
|
||||
|
||||
## 3. Berechtigungen
|
||||
|
||||
Die App fordert **keine einzige Berechtigung** an.
|
||||
|
||||
| Funktion | Technik | Berechtigung nötig? |
|
||||
|---|---|---|
|
||||
| Fotos auswählen | System-Photo-Picker | Nein |
|
||||
| Foto aufnehmen | System-Kamera-App über `ACTION_IMAGE_CAPTURE` (die App greift nicht selbst auf die Kamera zu) | Nein (`CAMERA` nicht deklariert) |
|
||||
| PDF speichern | Privater App-Speicher | Nein |
|
||||
| PDF öffnen/teilen | `FileProvider` + Android-Sharesheet | Nein |
|
||||
| Netzwerk | nicht vorhanden | `INTERNET` ist im Manifest **ausdrücklich entfernt** (`tools:node="remove"`) |
|
||||
|
||||
Durchsetzung: Das Manifest entfernt `INTERNET` und `ACCESS_NETWORK_STATE` beim Zusammenführen mit
|
||||
Bibliotheks-Manifesten. Der Test `PrivacyConfigurationTest` prüft das fertig zusammengeführte
|
||||
Manifest auf dem Gerät.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenflüsse nach außen
|
||||
|
||||
Daten verlassen das Gerät nur in diesen Fällen – jeweils durch eine bewusste Aktion des Nutzers:
|
||||
|
||||
1. **PDF öffnen** – Übergabe der PDF per `content://`-URI mit *Lese*recht an die gewählte PDF-App.
|
||||
2. **PDF teilen / Bilder teilen** – Übergabe per Android-Sharesheet mit *Lese*recht, nur
|
||||
`EXTRA_STREAM` (die Datei). Es wird **kein** Text, Betreff oder Empfänger mit Patientendaten
|
||||
vorbelegt.
|
||||
3. **Fotoimport** – Fotos werden *in* die App kopiert (Photo Picker / Kamera); die Originale
|
||||
verbleiben dort, wo sie der Nutzer gespeichert hat. Beim Import wird das Foto neu als JPEG
|
||||
kodiert; Metadaten des Originals (z. B. GPS-Position) werden dadurch **nicht** übernommen.
|
||||
|
||||
Nicht vorhanden: Netzwerkzugriff, Cloud-Backup, Geräte-zu-Geräte-Transfer, Analyse/Tracking,
|
||||
Absturzberichte, Werbung, Zwischenablage, Weitergabe an Dritte durch die App selbst.
|
||||
|
||||
**Verantwortung nach dem Teilen:** Sobald eine PDF an eine andere App übergeben wurde, liegt der
|
||||
weitere Umgang außerhalb der Kontrolle von VulneraDoc (z. B. E-Mail-Programm, Messenger,
|
||||
Cloud-Speicher). Das ist organisatorisch zu regeln (Abschnitt 9).
|
||||
|
||||
---
|
||||
|
||||
## 5. Technische und organisatorische Schutzmaßnahmen in der App
|
||||
|
||||
### 5.1 Speicherung
|
||||
- Ausschließlich privater interner Speicher (`filesDir`, `cacheDir`); kein `getExternalFilesDir`,
|
||||
keine `MediaStore`-Nutzung, keine `MODE_WORLD_*`-Dateien.
|
||||
- Dateien sind für andere Apps nicht lesbar (Android-App-Sandbox, Dateirechte).
|
||||
- **Pfadsicherheit:** Datei-/Ordnernamen werden über `SafePaths` aufgelöst (kein `../`, keine
|
||||
Pfadtrenner, keine Symlink-Ausbrüche).
|
||||
- **Atomares Schreiben der PDF:** Erst temporäre Datei, dann Umbenennen – kein halbfertiges PDF,
|
||||
eine vorhandene PDF geht bei einem Fehler nicht verloren.
|
||||
|
||||
### 5.2 Freigabe über FileProvider (Prinzip der minimalen Freigabe)
|
||||
Nur diese Ordner können nach außen gegeben werden:
|
||||
`files/pdf/`, `files/pdf_images/` (Exporte) und `cache/camera/` (Kameraaufnahme).
|
||||
**Nicht** freigegeben: Originalfotos (`photos/`), Unterschriften (`signatures/`), Datenbank,
|
||||
Wundversorger-Register. Der `FileProvider` ist nicht exportiert (`exported="false"`), Rechte werden
|
||||
nur pro Vorgang als Leserecht vergeben.
|
||||
|
||||
### 5.3 Backup
|
||||
- `android:allowBackup="false"` – kein Cloud-/Auto-Backup.
|
||||
- `data_extraction_rules.xml` schließt für `cloud-backup` **und** `device-transfer` die Bereiche
|
||||
`database`, `file` und `sharedpref` aus; es gibt kein `<include>`.
|
||||
- Folge: Beim Gerätewechsel oder nach Deinstallation sind die Daten **nicht** automatisch
|
||||
wiederherstellbar. Das ist gewollt (Datenschutz), muss aber organisatorisch bekannt sein
|
||||
(z. B. PDFs als Archiv in der Einrichtungsdokumentation ablegen).
|
||||
|
||||
### 5.4 Oberfläche
|
||||
- `FLAG_SECURE` im Hauptfenster: keine Screenshots, keine Bildschirmaufnahme, keine Vorschau im
|
||||
App-Wechsler.
|
||||
|
||||
### 5.5 Protokollierung (Logs)
|
||||
- Es gibt keine Konsolenausgaben und keinen `printStackTrace`-Aufruf.
|
||||
- Es existiert genau ein Log-Aufruf (Fehler beim Fotoimport). Er schreibt **nur den Typ** der
|
||||
Exception, nie die Fehlermeldung (diese kann Dateipfade oder URIs enthalten), nie Namen oder
|
||||
Patientendaten.
|
||||
- Fehlermeldungen in der Oberfläche sind allgemein formuliert und enthalten keine Pfade oder
|
||||
Exception-Texte.
|
||||
|
||||
### 5.6 Löschkonzept
|
||||
| Auslöser | Was wird gelöscht |
|
||||
|---|---|
|
||||
| Dokumentation löschen | Datenbankzeilen (inkl. Checklisten, Foto- und Signatur-Einträge), Fotos, Signaturen **und** zugehörige PDF- und Bildexporte |
|
||||
| Einzelnes Foto / einzelne Signatur löschen | Datenbankzeile und Datei |
|
||||
| Patient löschen | Nur möglich, wenn keine Dokumentation mehr existiert |
|
||||
| Automatisch beim App-Start | PDF-/Bildexporte älter als **7 Tage**; liegengebliebene Kameraaufnahmen älter als **24 Stunden** |
|
||||
| Deinstallation der App | Alle App-Daten (Android entfernt den privaten Speicher) |
|
||||
|
||||
Die automatisch gelöschten Exporte lassen sich jederzeit aus der Dokumentation neu erzeugen.
|
||||
Die Fristen stehen als Konstanten in `InternalExportedFilesCleaner` und können angepasst werden.
|
||||
|
||||
### 5.7 Fehlerbehandlung als Datenschutz-/Integritätsmaßnahme
|
||||
Fehlerfälle (Speicher voll, defekte Bilder, PDF-Fehler) führen nicht zu Datenverlust oder zu
|
||||
verwaisten Dateien (siehe Tests aus QA-004): fehlgeschlagene Importe hinterlassen keine
|
||||
Halbdateien, Datenbank und Dateien bleiben konsistent, Autosave-Fehler werden dem Nutzer gemeldet.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bekannte Restrisiken und Empfehlungen
|
||||
|
||||
| Nr. | Restrisiko | Bewertung | Empfehlung |
|
||||
|---|---|---|---|
|
||||
| R1 | **Datenbank und Dateien sind nicht zusätzlich verschlüsselt** (nur Android-Sandbox + Gerätespeicher-Verschlüsselung). Auf gerooteten Geräten oder bei kompromittiertem Gerät sind Daten lesbar. | mittel | Gerät mit Bildschirmsperre und aktivierter Speicherverschlüsselung betreiben (Standard seit Android 10). Bei erhöhtem Schutzbedarf Datenbankverschlüsselung (z. B. SQLCipher mit Schlüssel im Android Keystore) einplanen. |
|
||||
| R2 | **Keine App-eigene Sperre** (PIN/Biometrie). Wer das entsperrte Gerät hat, sieht die Daten. | mittel | Gerätesperre verbindlich vorschreiben; optional App-Sperre mit `BiometricPrompt` einführen. |
|
||||
| R3 | **Tastatur-Lernfunktion/Autofill:** Eingaben (Namen, Befund-Freitexte) können von der Bildschirmtastatur oder einem Autofill-Dienst mitgelernt werden. | gering–mittel | Geräte-Richtlinie: nur vertrauenswürdige Tastatur, Personalisierung/Cloud-Funktionen der Tastatur deaktivieren. |
|
||||
| R4 | **Dateiname des PDFs enthält Name und Geburtsdatum** und ist in Ziel-Apps (Mailanhang, Messenger) sichtbar. | gering–mittel | Fachliche Entscheidung: neutralen Dateinamen (z. B. nur Dokumentations-ID/Datum) anbieten. |
|
||||
| R5 | **Weitergabe über andere Apps** liegt nach dem Teilen außerhalb der App-Kontrolle. | organisatorisch | Zulässige Übertragungswege festlegen (z. B. nur dienstliches Postfach/Messenger); Mitarbeiter schulen. |
|
||||
| R6 | **Release-Build ohne Code-Verkleinerung/Obfuskation** (`isMinifyEnabled = false`). | gering | Für die Veröffentlichung R8 aktivieren (bessere Härtung gegen Reverse Engineering; kein Ersatz für Datenschutzmaßnahmen). |
|
||||
| R7 | **Kein automatischer Abgleich/Backup**: Geräteverlust oder Defekt = Datenverlust. | Verfügbarkeit | Organisatorisches Archiv (z. B. PDFs in die Patientenakte der Einrichtung). |
|
||||
| R8 | **Kamera-App des Herstellers** speichert Aufnahmen evtl. zusätzlich in der Galerie/Cloud (bei `ACTION_IMAGE_CAPTURE` mit Ziel-URI normalerweise nicht, herstellerabhängig). | gering | Auf Testgeräten prüfen, ob Aufnahmen zusätzlich in der Galerie erscheinen. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Automatisierte Datenschutz-Tests
|
||||
|
||||
| Test | Art | Prüft |
|
||||
|---|---|---|
|
||||
| `PrivacySourceScanTest` | JVM | Keine Netzwerk-/Tracking-Bibliotheken, keine `println`/`printStackTrace`, Log-Aufrufe ohne Nachricht/Pfad/Personendaten, kein externer Speicher, keine Zwischenablage, keine Texte in Share-Intents, Manifest ohne Berechtigungen, `allowBackup=false`, nur eine exportierte Komponente, Backup-Regeln, FileProvider-Freigaben |
|
||||
| `SafePathsTest` | JVM | Schutz vor Path-Traversal |
|
||||
| `ExportedFilesCleanerTest` | JVM | Löschen der Exporte, Aufbewahrungsfristen, kein Löschen außerhalb der Exportordner |
|
||||
| `PrivacyConfigurationTest` | Gerät/Emulator | Zusammengeführtes Manifest (Berechtigungen, exportierte Komponenten), Backup, FileProvider-Freigaben, private Dateirechte, `FLAG_SECURE` |
|
||||
| `DocumentationDeletionPrivacyTest` | Gerät/Emulator | Löschen einer Dokumentation entfernt auch die Exporte |
|
||||
|
||||
Diese Tests dienen als Regressionsschutz: Sie schlagen an, wenn künftig z. B. eine Berechtigung,
|
||||
eine Netzwerkbibliothek oder eine zu weite Freigabe hinzukommt.
|
||||
|
||||
---
|
||||
|
||||
## 8. Checkliste für Releases
|
||||
|
||||
- [ ] Alle Tests grün (insbesondere die Datenschutz-Tests aus Abschnitt 7)
|
||||
- [ ] Merged Manifest geprüft (Android Studio → `AndroidManifest.xml` → Tab *Merged Manifest*): keine neuen Berechtigungen/exportierten Komponenten
|
||||
- [ ] Neue Abhängigkeiten auf Netzwerk-/Tracking-Funktionen geprüft
|
||||
- [ ] Neue Log-Ausgaben enthalten keine Patientendaten, Pfade, URIs oder Exception-Nachrichten
|
||||
- [ ] Neue Speicherorte liegen im privaten Speicher; neue Freigaben sind in `file_paths.xml` minimal
|
||||
- [ ] Neue Datenfelder sind in Abschnitt 2 dieses Dokuments ergänzt und im Löschkonzept berücksichtigt
|
||||
- [ ] Release-Build signiert, nicht debuggable
|
||||
|
||||
---
|
||||
|
||||
## 9. Organisatorische Hinweise (für die verantwortliche Stelle)
|
||||
|
||||
Diese Punkte kann die App nicht technisch lösen:
|
||||
|
||||
- **Rechtsgrundlage** der Verarbeitung (Behandlungsvertrag / Dokumentationspflicht, Art. 9 Abs. 2
|
||||
lit. h DSGVO) und **Verzeichnis der Verarbeitungstätigkeiten** durch die verantwortliche Stelle
|
||||
- Prüfung, ob eine **Datenschutz-Folgenabschätzung** (Art. 35 DSGVO) erforderlich ist
|
||||
(Gesundheitsdaten, ggf. in größerem Umfang)
|
||||
- **Geräterichtlinie:** Bildschirmsperre, Geräteverschlüsselung, Updates, ggf. MDM, Regelung
|
||||
privater vs. dienstlicher Geräte
|
||||
- **Übertragungswege** für das Teilen von PDFs, Aufbewahrungs- und Löschfristen im Archiv
|
||||
- **Betroffenenrechte** (Auskunft, Berichtigung, Löschung): Löschung ist je Dokumentation in der
|
||||
App möglich; Auskunft über den PDF-Export
|
||||
- Schulung der Anwender, Meldeweg bei Geräteverlust (Meldepflicht bei Datenpannen, Art. 33 DSGVO)
|
||||
|
||||
## 10. Technische Eckdaten für Datenschutzdokumente
|
||||
|
||||
- Verarbeitung ausschließlich lokal auf dem Gerät; keine Empfänger durch die App
|
||||
- Keine Auftragsverarbeiter durch die App (kein Server, keine Cloud, keine Analyse-/Crash-Dienste)
|
||||
- Keine Datenübermittlung in Drittländer durch die App
|
||||
- Keine Cookies, kein Tracking, keine Werbe-ID
|
||||
- Speicherdauer: bis zur Löschung durch den Nutzer; Exporte automatisch nach 7 Tagen
|
||||
- Zugriffsschutz: Android-App-Sandbox, Gerätesperre; `FLAG_SECURE`
|
||||
@@ -0,0 +1,50 @@
|
||||
# QA-006 – Datenschutz-Review: Befunde und Maßnahmen
|
||||
|
||||
Geprüft wurde der Projektstand `dev-QA004` (Manifest, Backup-Regeln, FileProvider, Quelltext,
|
||||
Abhängigkeiten, Speicher-, Lösch- und Exportabläufe). Die App wurde **nicht** auf einem Gerät
|
||||
dynamisch untersucht (kein Netzwerk-Mitschnitt, kein Test der Hersteller-Kamera-App).
|
||||
|
||||
## Bereits gut umgesetzt (keine Änderung nötig)
|
||||
|
||||
| Prüfpunkt | Ergebnis |
|
||||
|---|---|
|
||||
| Berechtigungen | Keine deklariert; Photo Picker und System-Kamera-App statt eigener Kamera-/Speicherzugriffe |
|
||||
| Netzwerk | Kein Netzwerkcode, keine Netzwerk-/Tracking-Bibliotheken |
|
||||
| Backup | `allowBackup=false` und `data_extraction_rules` (Cloud-Backup + Geräte-Transfer) schließen alles aus |
|
||||
| Speicherort | Nur privater interner Speicher, kein öffentlicher/externer Speicher |
|
||||
| Bildschirm | `FLAG_SECURE` (keine Screenshots, keine App-Wechsler-Vorschau) |
|
||||
| Komponenten | Nur `MainActivity` exportiert; `FileProvider` nicht exportiert |
|
||||
| Share-Intents | Nur Datei (`EXTRA_STREAM`), kein Text/Betreff mit Patientendaten |
|
||||
| Datenbank | Keine destruktive Migration (kein stiller Datenverlust bei Updates) |
|
||||
| Metadaten in Fotos | Beim Import neu kodiert → GPS/EXIF des Originals wird nicht übernommen |
|
||||
| Fehlermeldungen in der UI | Ohne Pfade/Exception-Texte |
|
||||
|
||||
## Befunde und umgesetzte Maßnahmen
|
||||
|
||||
| Nr. | Befund | Schwere | Maßnahme | Datei(en) |
|
||||
|---|---|---|---|---|
|
||||
| B1 | **Log beim Fotoimport schrieb `e.message`.** Bei Dateifehlern enthält die Meldung häufig Pfad bzw. Bild-URI – entgegen dem Kommentar im Code. | mittel | Es wird nur noch der Exception-Typ geloggt. | `DocumentationEditViewModel.kt` |
|
||||
| B2 | **PDF- und Bildexporte blieben nach dem Löschen einer Dokumentation im Speicher** (enthalten Name, Geburtsdatum, Anschrift, Befund, Fotos). | hoch | Löschen der Dokumentation entfernt jetzt auch `pdf/<id>/` und `pdf_images/<id>/`. | `ExportedFilesCleaner.kt` (neu), `DocumentationRepository.kt`, `AppContainer.kt` |
|
||||
| B3 | **Exporte hatten keine Aufbewahrungsfrist.** Alte PDFs lagen unbegrenzt im App-Speicher. | mittel | Beim App-Start werden Exporte älter als 7 Tage gelöscht (jederzeit neu erzeugbar). | `ExportedFilesCleaner.kt`, `WunddokuApplication.kt` |
|
||||
| B4 | **Kameraaufnahmen konnten im Cache liegen bleiben**, wenn die App zwischen Aufnahme und Import beendet wurde. | gering–mittel | Aufnahmen älter als 24 h werden beim App-Start gelöscht (Frist bewusst lang, damit eine noch ausstehende Aufnahme nach einem Neustart importierbar bleibt). | `ExportedFilesCleaner.kt` |
|
||||
| B5 | **FileProvider gab `photos/` und `signatures/` frei**, obwohl nie darüber geteilt wird. | gering (Prinzip der minimalen Freigabe) | Freigaben auf `pdf/`, `pdf_images/`, `cache/camera/` reduziert. | `file_paths.xml` |
|
||||
| B6 | **`INTERNET` konnte über eine künftige Bibliothek unbemerkt ins Manifest gelangen.** | gering (Vorsorge) | `INTERNET` und `ACCESS_NETWORK_STATE` werden beim Manifest-Merge explizit entfernt. | `AndroidManifest.xml` |
|
||||
| B7 | **Pfadauflösung ohne Traversal-Schutz** (`File(dir, name)`). Namen stammen aus der eigenen DB (UUIDs), daher kein akutes Risiko. | gering (Defense-in-Depth) | `SafePaths.resolveChild` in Foto-/Signatur-Speicher und Cleaner. | `SafePaths.kt` (neu), `PhotoStorage.kt`, `SignatureStorage.kt` |
|
||||
|
||||
## Offene Punkte (bewusst NICHT geändert – Produktentscheidung nötig)
|
||||
|
||||
| Nr. | Thema | Empfehlung |
|
||||
|---|---|---|
|
||||
| O1 | Datenbank/Dateien nicht zusätzlich verschlüsselt (Restrisiko R1) | Bei erhöhtem Schutzbedarf SQLCipher + Android Keystore; sonst Geräteverschlüsselung + Sperre vorschreiben |
|
||||
| O2 | Keine App-Sperre (PIN/Biometrie) (R2) | `BiometricPrompt` beim Start/Wiederöffnen erwägen |
|
||||
| O3 | PDF-Dateiname enthält Name + Geburtsdatum (R4) | Neutralen Dateinamen als Option; würde `PdfFileNameGenerator` und dessen Tests ändern |
|
||||
| O4 | Tastatur-Lernfunktion/Autofill (R3) | Geräterichtlinie; in Compose nur eingeschränkt steuerbar |
|
||||
| O5 | `isMinifyEnabled = false` (R6) | R8 vor Veröffentlichung aktivieren und Release-Build testen |
|
||||
| O6 | Dynamische Prüfung auf echten Geräten (Netzwerkmitschnitt, Hersteller-Kamera, Galerie-Kopien) | Vor Produktivbetrieb einmal durchführen |
|
||||
|
||||
## Wichtig zu den Änderungen B2/B3
|
||||
|
||||
B2 und B3 ändern das Verhalten: Exportierte PDFs verschwinden nach 7 Tagen und beim Löschen der
|
||||
Dokumentation. Wenn die App PDFs länger vorhalten soll, `DEFAULT_EXPORT_MAX_AGE_MILLIS` in
|
||||
`InternalExportedFilesCleaner` erhöhen. Bereits **geteilte** Kopien in anderen Apps sind davon
|
||||
natürlich nicht betroffen.
|
||||
Reference in New Issue
Block a user