Compare commits
17 Commits
dev-Inhalte
...
dev-QA005
| Author | SHA1 | Date | |
|---|---|---|---|
| e9403801d6 | |||
| 18a7af800f | |||
| 51370a9172 | |||
| 26cc81388e | |||
| dcfa11bef9 | |||
| e6bc99d144 | |||
| 2bc0d959e1 | |||
| b77f7f0f20 | |||
| 70bdc84cfa | |||
| cefecb86dd | |||
| 1fdcceb149 | |||
| 8a430d5089 | |||
| 0c7eaabbb7 | |||
| 63bc93ebd2 | |||
| 0f4ce2d2e1 | |||
| 08091a15a2 | |||
| 4a62d195ac |
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
|
|||||||
unterstützt Fotos und digitale Unterschriften und erzeugt aus
|
unterstützt Fotos und digitale Unterschriften und erzeugt aus
|
||||||
abgeschlossenen Dokumentationen PDF-Dateien.
|
abgeschlossenen Dokumentationen PDF-Dateien.
|
||||||
|
|
||||||
> **Projektstatus:** In Entwicklung
|
> **Projektstatus:** beta1.1
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -344,32 +344,6 @@ werden kann.
|
|||||||
- JDK über Android Studio
|
- JDK über Android Studio
|
||||||
- Android-Gerät oder Android Emulator
|
- 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
|
## Projektphasen
|
||||||
@@ -387,40 +361,40 @@ Run
|
|||||||
### Phase 2 -- Projektgrundgerüst
|
### Phase 2 -- Projektgrundgerüst
|
||||||
|
|
||||||
- [x] Android-Projekt
|
- [x] Android-Projekt
|
||||||
- [ ] Room-Datenbank
|
- [x] Room-Datenbank
|
||||||
- [ ] Entities
|
- [x] Entities
|
||||||
- [ ] DAOs
|
- [x] DAOs
|
||||||
- [ ] Repositories
|
- [x] Repositories
|
||||||
- [ ] Navigation
|
- [x] Navigation
|
||||||
- [ ] ViewModels
|
- [x] ViewModels
|
||||||
|
|
||||||
### Phase 3 -- Kernfunktionalität
|
### Phase 3 -- Kernfunktionalität
|
||||||
|
|
||||||
- [ ] Patientenverwaltung
|
- [x] Patientenverwaltung
|
||||||
- [ ] Patientenliste
|
- [x] Patientenliste
|
||||||
- [ ] Wunddokumentationsliste
|
- [x] Wunddokumentationsliste
|
||||||
- [ ] Dokumentation erstellen
|
- [x] Dokumentation erstellen
|
||||||
- [ ] Dokumentationsblöcke
|
- [x] Dokumentationsblöcke
|
||||||
- [ ] Blocknavigation
|
- [x] Blocknavigation
|
||||||
- [ ] Autosave
|
- [x] Autosave
|
||||||
- [ ] Dokumentation bearbeiten
|
- [x] Dokumentation bearbeiten
|
||||||
- [ ] Dokumentation fortsetzen
|
- [x] Dokumentation fortsetzen
|
||||||
|
|
||||||
### Phase 4 -- Medien
|
### Phase 4 -- Medien
|
||||||
|
|
||||||
- [ ] Fotoimport
|
- [x] Fotoimport
|
||||||
- [ ] Bildverwaltung
|
- [x] Bildverwaltung
|
||||||
- [ ] Digitale Signatur
|
- [x] Digitale Signatur
|
||||||
- [ ] Mehrere Signaturen
|
- [x] Mehrere Signaturen
|
||||||
|
|
||||||
### Phase 5 -- PDF
|
### Phase 5 -- PDF
|
||||||
|
|
||||||
- [ ] PDF-Exporter
|
- [x] PDF-Exporter
|
||||||
- [ ] Dokumentationsblöcke
|
- [x] Dokumentationsblöcke
|
||||||
- [ ] Signaturen
|
- [x] Signaturen
|
||||||
- [ ] Fotoanhang
|
- [x] Fotoanhang
|
||||||
- [ ] PDF anzeigen
|
- [x] PDF anzeigen
|
||||||
- [ ] PDF teilen
|
- [x] PDF teilen
|
||||||
|
|
||||||
### Phase 6 -- Qualität und Stabilität
|
### Phase 6 -- Qualität und Stabilität
|
||||||
|
|
||||||
@@ -431,29 +405,15 @@ Run
|
|||||||
- [ ] Tablet-Optimierung
|
- [ ] Tablet-Optimierung
|
||||||
- [ ] Datenschutz-Review
|
- [ ] Datenschutz-Review
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
### Phase 7 -- Multi-Device-Synchronisation
|
||||||
|
|
||||||
## Dokumentation
|
- [ ] Geräteidentität und Pairing
|
||||||
|
- [ ] Automatische Geräteerkennung im WLAN
|
||||||
Die ausführliche technische Architektur befindet sich in der
|
- [ ] Differenzabgleich / Sync-Protokoll
|
||||||
Projektdokumentation:
|
- [ ] Patienten synchronisieren
|
||||||
|
- [ ] Wunddokumentationen synchronisieren
|
||||||
``` text
|
- [ ] Fotos synchronisieren
|
||||||
docs/
|
- [ ] Konflikterkennung und Konfliktauflösung
|
||||||
└── Architekturplanung.md
|
- [ ] Automatische Synchronisation
|
||||||
```
|
- [ ] Sync-Status und manueller Fallback
|
||||||
|
- [ ] Multi-Device- und Recovery-Tests
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,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,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")
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
|
|||||||
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
|
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
|
||||||
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
|
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
|
||||||
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
|
* 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(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -59,7 +64,7 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
|
|||||||
SignatureEntity::class,
|
SignatureEntity::class,
|
||||||
WoundCarerEntity::class,
|
WoundCarerEntity::class,
|
||||||
],
|
],
|
||||||
version = 5,
|
version = 6,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(DocumentationStatusConverter::class)
|
@TypeConverters(DocumentationStatusConverter::class)
|
||||||
@@ -87,7 +92,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
DATABASE_NAME,
|
DATABASE_NAME,
|
||||||
)
|
)
|
||||||
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
|
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
|
||||||
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
||||||
.build().also { instance = it }
|
.build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,3 +131,20 @@ val MIGRATION_4_5 = object : Migration(4, 5) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ''"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,13 +20,22 @@ import androidx.room.PrimaryKey
|
|||||||
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
|
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
|
||||||
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
|
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
|
||||||
*
|
*
|
||||||
* [isStaticHint]: NEU. Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B.
|
* [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
|
* "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden
|
||||||
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
|
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
|
||||||
* fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel
|
* 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
|
* im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden
|
||||||
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
|
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
|
||||||
* "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt.
|
* "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(
|
@Entity(
|
||||||
tableName = "check_item_states",
|
tableName = "check_item_states",
|
||||||
@@ -53,4 +62,5 @@ data class CheckItemStateEntity(
|
|||||||
val isFreeText: Boolean = false,
|
val isFreeText: Boolean = false,
|
||||||
val freeTextValue: String = "",
|
val freeTextValue: String = "",
|
||||||
val isStaticHint: Boolean = false,
|
val isStaticHint: Boolean = false,
|
||||||
|
val inputSpec: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
|
|||||||
checked = checked,
|
checked = checked,
|
||||||
isFreeText = isFreeText,
|
isFreeText = isFreeText,
|
||||||
isStaticHint = isStaticHint,
|
isStaticHint = isStaticHint,
|
||||||
|
inputSpec = inputSpec,
|
||||||
freeTextValue = freeTextValue,
|
freeTextValue = freeTextValue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
|
|||||||
import de.medapp.wunddoku.domain.model.Documentation
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
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.Photo
|
||||||
import de.medapp.wunddoku.domain.model.Signature
|
import de.medapp.wunddoku.domain.model.Signature
|
||||||
import de.medapp.wunddoku.media.ImageDownsampler
|
import de.medapp.wunddoku.media.ImageDownsampler
|
||||||
@@ -29,10 +30,13 @@ import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
|||||||
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
||||||
import de.medapp.wunddoku.pdf.PdfExportError
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
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.SignatureBitmapValidation
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -60,6 +64,7 @@ class DocumentationRepository(
|
|||||||
private val signatureStorage: SignatureStorage,
|
private val signatureStorage: SignatureStorage,
|
||||||
private val woundCarerRepository: WoundCarerRepository,
|
private val woundCarerRepository: WoundCarerRepository,
|
||||||
private val pdfExporter: DocumentationPdfExporter,
|
private val pdfExporter: DocumentationPdfExporter,
|
||||||
|
private val pdfToImagesConverter: PdfToImagesConverter,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -187,6 +192,7 @@ class DocumentationRepository(
|
|||||||
isFreeText = item.isFreeText,
|
isFreeText = item.isFreeText,
|
||||||
isStaticHint = item.isStaticHint,
|
isStaticHint = item.isStaticHint,
|
||||||
freeTextValue = item.freeTextValue,
|
freeTextValue = item.freeTextValue,
|
||||||
|
inputSpec = item.inputSpec,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -284,6 +290,16 @@ class DocumentationRepository(
|
|||||||
return pdfExporter.export(model)
|
return pdfExporter.export(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert eine bereits erzeugte PDF-Datei (s. [exportPdf]) seitenweise in JPEG-Dateien
|
||||||
|
* (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können"). Nimmt bewusst die
|
||||||
|
* fertige [pdfFile] entgegen, statt die Dokumentation erneut zu laden/zu mappen - der
|
||||||
|
* Aufrufer hat sie direkt aus einem vorangegangenen [exportPdf]-Aufruf zur Verfügung, ein
|
||||||
|
* erneutes Laden wäre unnötige Doppelarbeit.
|
||||||
|
*/
|
||||||
|
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
|
||||||
|
pdfToImagesConverter.convert(documentationId, pdfFile)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
|
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
|
||||||
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
|
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
|
||||||
@@ -385,10 +401,11 @@ class DocumentationRepository(
|
|||||||
* nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine
|
* 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.
|
* zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet.
|
||||||
*
|
*
|
||||||
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText` und
|
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText`,
|
||||||
* `isStaticHint` steuern direkt das Verhalten der jeweiligen Zeile (normale Checkbox,
|
* `isStaticHint` und `inputSpec` steuern direkt das Verhalten der jeweiligen Zeile (normale
|
||||||
* eigene benannte Freitext-Checkbox, oder nicht editierbarer Hinweistext) - keine
|
* Checkbox, eigene benannte Freitext-Checkbox, nicht editierbarer Hinweistext, oder
|
||||||
* block-spezifische Sonderlogik nötig, die Katalogdefinition allein entscheidet.
|
* strukturierte Eingabefelder) - keine block-spezifische Sonderlogik nötig, die
|
||||||
|
* Katalogdefinition allein entscheidet.
|
||||||
*
|
*
|
||||||
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
|
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
|
||||||
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
|
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
|
||||||
@@ -396,11 +413,14 @@ class DocumentationRepository(
|
|||||||
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
|
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
|
||||||
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
|
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
|
||||||
*
|
*
|
||||||
* Die generische Freitext-Option erscheint als ERSTES Element im Block (itemOrder = 0,
|
* Änderungswunsch: Die generische Freitext-Option erscheint jetzt als LETZTES Element im
|
||||||
* niedriger als jeder Katalog-Eintrag, dessen `order` bei 1 beginnt). Betrifft ausschließlich
|
* Block (itemOrder = höchste vorkommende Katalog-`order` + 1) - zuvor war sie das ERSTE
|
||||||
* NEU erstellte Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen
|
* Element (itemOrder = 0). Vom Katalog selbst definierte, eigens benannte Freitext-Einträge
|
||||||
* behalten ihre historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s.
|
* (isFreeText = true) behalten dagegen ihre im Katalog festgelegte Position, sofern der
|
||||||
* Klassendokumentation zu titleSnapshot/labelSnapshot).
|
* 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) {
|
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
|
||||||
for (blockDef in DocumentationCatalog.blocks) {
|
for (blockDef in DocumentationCatalog.blocks) {
|
||||||
@@ -426,24 +446,29 @@ class DocumentationRepository(
|
|||||||
labelSnapshot = itemDef.label,
|
labelSnapshot = itemDef.label,
|
||||||
itemOrder = itemDef.order,
|
itemOrder = itemDef.order,
|
||||||
// Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
|
// Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
|
||||||
// anderen Einträge (Checkbox oder eigene Freitext-Checkbox) starten
|
// anderen Einträge (Checkbox, eigene Freitext-Checkbox oder strukturierte
|
||||||
// unangehakt/leer, wie bisher.
|
// Eingabe) starten unangehakt/leer, wie bisher.
|
||||||
checked = itemDef.isStaticHint,
|
checked = itemDef.isStaticHint,
|
||||||
isFreeText = itemDef.isFreeText,
|
isFreeText = itemDef.isFreeText,
|
||||||
isStaticHint = itemDef.isStaticHint,
|
isStaticHint = itemDef.isStaticHint,
|
||||||
freeTextValue = "",
|
freeTextValue = "",
|
||||||
|
inputSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Ä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(
|
val genericFreeTextItem = CheckItemStateEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
blockStateId = blockStateId,
|
blockStateId = blockStateId,
|
||||||
checkItemDefinitionId = blockDef.freeTextDefinitionId,
|
checkItemDefinitionId = blockDef.freeTextDefinitionId,
|
||||||
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
|
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
|
||||||
itemOrder = 0,
|
itemOrder = genericFreeTextOrder,
|
||||||
checked = false,
|
checked = false,
|
||||||
isFreeText = true,
|
isFreeText = true,
|
||||||
isStaticHint = false,
|
isStaticHint = false,
|
||||||
freeTextValue = "",
|
freeTextValue = "",
|
||||||
|
inputSpec = "",
|
||||||
)
|
)
|
||||||
checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
|
checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import de.medapp.wunddoku.data.repository.WoundCarerRepository
|
|||||||
import de.medapp.wunddoku.media.InternalPhotoStorage
|
import de.medapp.wunddoku.media.InternalPhotoStorage
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
||||||
|
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
|
||||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||||
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ class AppContainer(context: Context) {
|
|||||||
private val pdfExporter: DocumentationPdfExporter by lazy {
|
private val pdfExporter: DocumentationPdfExporter by lazy {
|
||||||
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
|
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
|
||||||
}
|
}
|
||||||
|
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
|
||||||
|
|
||||||
val patientRepository: PatientRepository by lazy {
|
val patientRepository: PatientRepository by lazy {
|
||||||
PatientRepository(patientDao = database.patientDao())
|
PatientRepository(patientDao = database.patientDao())
|
||||||
@@ -62,6 +65,7 @@ class AppContainer(context: Context) {
|
|||||||
signatureStorage = signatureStorage,
|
signatureStorage = signatureStorage,
|
||||||
woundCarerRepository = woundCarerRepository,
|
woundCarerRepository = woundCarerRepository,
|
||||||
pdfExporter = pdfExporter,
|
pdfExporter = pdfExporter,
|
||||||
|
pdfToImagesConverter = pdfToImagesConverter,
|
||||||
db = database,
|
db = database,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,94 @@
|
|||||||
package de.medapp.wunddoku.domain.catalog
|
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.
|
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
|
||||||
*
|
*
|
||||||
* Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
|
* Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
|
||||||
* handschriftlich vorgegebene 22-Punkte-Reihenfolge, ergänzt/verfeinert nach Abgleich mit dem
|
* handschriftlich vorgegebene 22-Punkte-Reihenfolge, zunächst ergänzt/verfeinert nach Abgleich
|
||||||
* ursprünglichen Papierformular ("Wunddokumentation" von Harald Klock) - dabei wurden:
|
* 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").
|
||||||
*
|
*
|
||||||
* - mehrere Abschnitte, die im Chat/der handschriftlichen Liste nur als Freitext geführt waren,
|
* WICHTIGE GENERELLE REGEL (Änderungswunsch): Die automatisch an jeden Block angehängte
|
||||||
* auf dem Papierbogen aber eine differenzierte Checkbox-Struktur zeigen, entsprechend
|
* generische Freitext-Option (s. [FREE_TEXT_LABEL]) erscheint jetzt als LETZTES Element eines
|
||||||
* erweitert (Wundgrund, Belag, Wundrand, Exsudat, Größe, Fistel/Tasche, Lokalisation,
|
* Blocks (vorher: als erstes) - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.insertBlocksFromCatalog].
|
||||||
* Empfohlene Wundversorgung),
|
* Vom Katalog selbst eigens benannte Freitext-Einträge (`isFreeText = true` an einem konkreten
|
||||||
* - zwei auf dem Papierbogen vorhandene, aber in der handschriftlichen Liste fehlende
|
* [CheckItemDefinition]) behalten dagegen ihre hier festgelegte Position.
|
||||||
* Abschnitte wieder aufgenommen ("Weitere Überlegung" - vermutlich versehentlich in der
|
|
||||||
* handschriftlichen Liste ausgelassen; "Nächster Termin" - auf dem Bogen als eigenes Feld
|
|
||||||
* vorhanden),
|
|
||||||
* - "Verlauf" bewusst NICHT um die alten Papier-Checkboxen erweitert (explizit vom Kunden
|
|
||||||
* bestätigt: bleibt reiner Freitext, die alte Checkbox-Auswahl wurde bewusst verworfen),
|
|
||||||
* - "Wundumgebung" und "Auswirkungen auf das alltägliche Leben" um die Papier-Checkbox-Listen
|
|
||||||
* ergänzt, jeweils MIT vorangestelltem freiem Textfeld (die ohnehin an jedem Block
|
|
||||||
* automatisch vorhandene generische Freitext-Option deckt das ab, s. [FREE_TEXT_LABEL]),
|
|
||||||
* - "Wundart" um die Papier-Checkbox "nicht bekannt/diagnostiziert" ergänzt (zusätzlich zum
|
|
||||||
* Freitext - NICHT Teil der früher verworfenen Wundarten-Liste, sondern ein eigenständiges
|
|
||||||
* Status-Flag).
|
|
||||||
*
|
*
|
||||||
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
|
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
|
||||||
* Inhalte (Wundart-Typenliste, Verlauf-Checkboxen, "Schmerzempfinden 1-10") sind weiterhin
|
* Inhalte (Wortlaut-Variante der alten Wundart-Checkboxen, "Schmerzempfinden 1-10") sind
|
||||||
* bewusst NICHT enthalten.
|
* weiterhin bewusst NICHT enthalten.
|
||||||
*
|
*
|
||||||
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
|
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
|
||||||
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
|
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
|
||||||
* Abschnitt 2.3b.
|
* Abschnitt 2.3b.
|
||||||
*
|
*
|
||||||
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
|
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
|
||||||
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
|
* Metadatum in Documentation.catalogVersion übernommen. Auf 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
|
||||||
* 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). Auf 3 erhöht mit dem
|
|
||||||
* Abgleich gegen das ursprüngliche Papierformular.
|
|
||||||
*/
|
*/
|
||||||
object DocumentationCatalog {
|
object DocumentationCatalog {
|
||||||
|
|
||||||
const val CATALOG_VERSION: Int = 3
|
const val CATALOG_VERSION: Int = 5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
|
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
|
||||||
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral
|
* damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe Bezeichnung verwenden.
|
||||||
* hier definiert, damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe
|
* Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die fachlich "nur
|
||||||
* Bezeichnung verwenden. Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die
|
* Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
|
||||||
* fachlich "nur Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
|
|
||||||
*/
|
*/
|
||||||
const val FREE_TEXT_LABEL: String = "Freitext"
|
const val FREE_TEXT_LABEL: String = "Freitext"
|
||||||
|
|
||||||
|
// 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(
|
val blocks: List<BlockDefinition> = listOf(
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "wundart",
|
id = "wundart",
|
||||||
title = "Wundart",
|
title = "Wundart",
|
||||||
order = 1,
|
order = 1,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "nicht bekannt/diagnostiziert", order = 1),
|
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
|
||||||
|
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "wundart_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(
|
BlockDefinition(
|
||||||
@@ -74,18 +104,37 @@ object DocumentationCatalog {
|
|||||||
title = "Größe",
|
title = "Größe",
|
||||||
order = 3,
|
order = 3,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "multiple Wunden mit max. (cm)", order = 1, isFreeText = true),
|
CheckItemDefinition(id = "groesse_hinweis_masse", label = "Maße", order = 1, isStaticHint = true),
|
||||||
CheckItemDefinition(id = "groesse_wunde", label = "Wunde", order = 2),
|
CheckItemDefinition(
|
||||||
CheckItemDefinition(id = "groesse_areal", label = "Areal", order = 3),
|
id = "groesse_laenge_breite_tiefe",
|
||||||
CheckItemDefinition(id = "groesse_masse", label = "Maße (Länge x Breite, ca. cm)", order = 4, isFreeText = true),
|
label = "Maße (Länge / Breite / Tiefe)",
|
||||||
CheckItemDefinition(id = "groesse_vertikale_tiefe", label = "Vertikale Tiefe (ca. cm)", order = 5, isFreeText = true),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
CheckItemDefinition(id = "groesse_hinweis_areal", label = "Zu versorgendes Areal", order = 5, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6),
|
||||||
|
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "verlauf",
|
id = "verlauf",
|
||||||
title = "Verlauf",
|
title = "Verlauf",
|
||||||
order = 4,
|
order = 4,
|
||||||
items = emptyList(),
|
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(
|
BlockDefinition(
|
||||||
id = "exsudat",
|
id = "exsudat",
|
||||||
@@ -114,21 +163,26 @@ object DocumentationCatalog {
|
|||||||
title = "Wundgrund + Erscheinung",
|
title = "Wundgrund + Erscheinung",
|
||||||
order = 6,
|
order = 6,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 1),
|
CheckItemDefinition(id = "wundgrund_wie_ausgestanzt", label = "Wie ausgestanzt", order = 1),
|
||||||
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 2),
|
CheckItemDefinition(id = "wundgrund_kraterfoermig", label = "Kraterförmig", order = 2),
|
||||||
CheckItemDefinition(id = "wundgrund_knospig", label = "Knospig", order = 3),
|
CheckItemDefinition(id = "wundgrund_unregelmaessig_landkartenartig", label = "Unregelmäßig (landkartenartig/amorph)", order = 3),
|
||||||
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 4),
|
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 4),
|
||||||
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 5),
|
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 5),
|
||||||
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 6),
|
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 6),
|
||||||
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 7),
|
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 7),
|
||||||
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 8),
|
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 8),
|
||||||
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 9),
|
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 9),
|
||||||
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 10),
|
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 10),
|
||||||
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 11),
|
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 11),
|
||||||
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 12),
|
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 12),
|
||||||
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 13),
|
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 13),
|
||||||
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 14),
|
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 14),
|
||||||
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 15, isFreeText = true),
|
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(
|
BlockDefinition(
|
||||||
@@ -142,13 +196,14 @@ object DocumentationCatalog {
|
|||||||
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
|
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
|
||||||
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
|
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
|
||||||
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
|
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
|
||||||
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 7),
|
CheckItemDefinition(id = "belag_biofilm", label = "Biofilm", order = 7),
|
||||||
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 8),
|
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 8),
|
||||||
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 9),
|
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 9),
|
||||||
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 10),
|
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 10),
|
||||||
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 11),
|
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 11),
|
||||||
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 12),
|
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 12),
|
||||||
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 13),
|
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 13),
|
||||||
|
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
@@ -190,10 +245,11 @@ object DocumentationCatalog {
|
|||||||
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
|
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
|
||||||
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
|
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
|
||||||
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
|
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
|
||||||
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 11),
|
CheckItemDefinition(id = "wundumgebung_sehr_trockene_haut", label = "Sehr trockene Haut", order = 11),
|
||||||
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 12),
|
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 12),
|
||||||
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 13),
|
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 13),
|
||||||
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 14),
|
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 14),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 15),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
@@ -204,8 +260,27 @@ object DocumentationCatalog {
|
|||||||
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
|
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
|
||||||
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
|
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
|
||||||
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
|
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
|
||||||
CheckItemDefinition(id = "fistel_tasche_von_bis_uhr", label = "Von ... bis ... Uhr", order = 4, isFreeText = true),
|
CheckItemDefinition(
|
||||||
CheckItemDefinition(id = "fistel_tasche_tiefe", label = "Tiefe (ca. cm)", order = 5, isFreeText = true),
|
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(
|
BlockDefinition(
|
||||||
@@ -264,28 +339,118 @@ object DocumentationCatalog {
|
|||||||
id = "vitalwerte",
|
id = "vitalwerte",
|
||||||
title = "Vitalwerte",
|
title = "Vitalwerte",
|
||||||
order = 15,
|
order = 15,
|
||||||
items = emptyList(),
|
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(
|
BlockDefinition(
|
||||||
id = "auswirkungen_alltag",
|
id = "auswirkungen_alltag",
|
||||||
title = "Auswirkungen auf das alltägliche Leben",
|
title = "Auswirkungen auf das alltägliche Leben",
|
||||||
order = 16,
|
order = 17,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "auswirkungen_alltag_ja", label = "Ja", order = 1),
|
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 1),
|
||||||
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 2),
|
CheckItemDefinition(id = "auswirkungen_alltag_schmerz", label = "Schmerz", order = 2),
|
||||||
CheckItemDefinition(id = "auswirkungen_alltag_nicht_ermittelbar", label = "Nicht ermittelbar", order = 3),
|
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(
|
BlockDefinition(
|
||||||
id = "bekannte_allergie",
|
id = "bekannte_allergie",
|
||||||
title = "Bekannte Allergie",
|
title = "Bekannte Allergie",
|
||||||
order = 17,
|
order = 18,
|
||||||
items = emptyList(),
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "bekannte_allergie_ja", label = "Ja", order = 1),
|
||||||
|
CheckItemDefinition(id = "bekannte_allergie_nein", label = "Nein", order = 2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "beratung_erfolgte",
|
id = "beratung_erfolgte",
|
||||||
title = "Beratung erfolgte",
|
title = "Beratung erfolgte",
|
||||||
order = 18,
|
order = 19,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
|
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
|
||||||
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
|
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
|
||||||
@@ -298,7 +463,7 @@ object DocumentationCatalog {
|
|||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "weitere_ueberlegung",
|
id = "weitere_ueberlegung",
|
||||||
title = "Weitere Überlegung",
|
title = "Weitere Überlegung",
|
||||||
order = 19,
|
order = 20,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
|
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
|
||||||
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
|
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
|
||||||
@@ -321,46 +486,73 @@ object DocumentationCatalog {
|
|||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "sonstiges",
|
id = "sonstiges",
|
||||||
title = "Sonstiges",
|
title = "Sonstiges",
|
||||||
order = 20,
|
order = 21,
|
||||||
items = emptyList(),
|
items = emptyList(),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "empfohlene_wundversorgung",
|
id = "empfohlene_wundversorgung",
|
||||||
title = "Empfohlene Wundversorgung",
|
title = "Empfohlene Wundversorgung",
|
||||||
order = 21,
|
order = 22,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true),
|
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_sterile_kompresse", label = "Sterile Kompresse", order = 2),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
|
// Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_getraenkte_kompresse", label = "Getränkte Kompresse für ca. ... Min. auflegen", order = 6, isFreeText = true),
|
// 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_tupfend", label = "Tupfend", order = 7),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
|
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_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_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_secura", label = "Secura", order = 15),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_cavilon", label = "Cavilon", order = 16),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 16),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 17, isStaticHint = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 17),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 18),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_freitext", label = FREE_TEXT_LABEL, order = 18, isFreeText = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 19),
|
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 20, isStaticHint = true),
|
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 21),
|
CheckItemDefinition(
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 22),
|
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 23),
|
label = "Wie oft pro Woche",
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 24),
|
order = 19,
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 25),
|
inputSpec = InputSpec(
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband", label = "Kompressionsverband", order = 26),
|
pdfTemplate = "{0}x pro Woche",
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 27, isFreeText = true),
|
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 28, isStaticHint = true),
|
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(
|
CheckItemDefinition(
|
||||||
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
||||||
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
||||||
order = 29,
|
order = 35,
|
||||||
isStaticHint = true,
|
isStaticHint = true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -368,7 +560,7 @@ object DocumentationCatalog {
|
|||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "ziel",
|
id = "ziel",
|
||||||
title = "Ziel",
|
title = "Ziel",
|
||||||
order = 22,
|
order = 23,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
|
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
|
||||||
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
|
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
|
||||||
@@ -381,12 +573,13 @@ object DocumentationCatalog {
|
|||||||
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
|
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
|
||||||
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
|
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
|
||||||
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
|
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(
|
BlockDefinition(
|
||||||
id = "kopie_nachrichtlich_an",
|
id = "kopie_nachrichtlich_an",
|
||||||
title = "Kopie nachrichtlich an",
|
title = "Kopie nachrichtlich an",
|
||||||
order = 23,
|
order = 24,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true),
|
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_pflege", label = "Pflege", order = 2, isFreeText = true),
|
||||||
@@ -397,7 +590,7 @@ object DocumentationCatalog {
|
|||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "naechster_termin",
|
id = "naechster_termin",
|
||||||
title = "Nächster Termin",
|
title = "Nächster Termin",
|
||||||
order = 24,
|
order = 25,
|
||||||
items = emptyList(),
|
items = emptyList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -425,7 +618,7 @@ data class BlockDefinition(
|
|||||||
/**
|
/**
|
||||||
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
|
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
|
||||||
*
|
*
|
||||||
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false).
|
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false, [inputSpec] = null).
|
||||||
*
|
*
|
||||||
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
|
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
|
||||||
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
|
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
|
||||||
@@ -437,10 +630,15 @@ data class BlockDefinition(
|
|||||||
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
|
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
|
||||||
* Text selbst, keine Checkbox-Beschriftung.
|
* Text selbst, keine Checkbox-Beschriftung.
|
||||||
*
|
*
|
||||||
* [isFreeText] und [isStaticHint] schließen sich fachlich gegenseitig aus (ein Eintrag ist
|
* [inputSpec] != null: dieser Eintrag besitzt statt eines einfachen Häkchens ein oder mehrere
|
||||||
* entweder normale Checkbox, Freitext-Checkbox oder Hinweistext) - es gibt aber bewusst keine
|
* strukturierte Eingabefelder (Zahl oder Auswahlliste, z. B. "Von [ ] bis [ ] Uhr" oder
|
||||||
* Laufzeitprüfung dafür, um die Katalogdefinition einfach zu halten; beide gleichzeitig auf
|
* "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
|
||||||
* `true` zu setzen ergibt schlicht keinen sinnvollen Katalogeintrag.
|
*
|
||||||
|
* [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(
|
data class CheckItemDefinition(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -448,4 +646,5 @@ data class CheckItemDefinition(
|
|||||||
val order: Int,
|
val order: Int,
|
||||||
val isFreeText: Boolean = false,
|
val isFreeText: Boolean = false,
|
||||||
val isStaticHint: Boolean = false,
|
val isStaticHint: Boolean = false,
|
||||||
|
val inputSpec: InputSpec? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ package de.medapp.wunddoku.domain.model
|
|||||||
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
|
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
|
||||||
* Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity-
|
* 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]).
|
* 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(
|
data class CheckItem(
|
||||||
val checkItemStateId: String,
|
val checkItemStateId: String,
|
||||||
@@ -30,4 +35,5 @@ data class CheckItem(
|
|||||||
val isFreeText: Boolean = false,
|
val isFreeText: Boolean = false,
|
||||||
val freeTextValue: String = "",
|
val freeTextValue: String = "",
|
||||||
val isStaticHint: Boolean = false,
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import android.content.Context
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.graphics.pdf.PdfDocument
|
import android.graphics.pdf.PdfDocument
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
|
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -98,6 +100,14 @@ class AndroidPdfDocumentExporter(
|
|||||||
*/
|
*/
|
||||||
private const val PHOTO_TARGET_DPI = 150
|
private const val PHOTO_TARGET_DPI = 150
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderungswunsch: Bilder im Fotoanhang werden auf 50 % ihrer bisherigen Größe
|
||||||
|
* ausgegeben (Breite und Höhe je halbiert, Seitenverhältnis unverändert). Derselbe
|
||||||
|
* Faktor gilt für die Decode-Auflösung (s. [PHOTO_MAX_WIDTH_PX]), sodass die
|
||||||
|
* Bilddichte bei [PHOTO_TARGET_DPI] bleibt und das PDF zugleich deutlich kleiner wird.
|
||||||
|
*/
|
||||||
|
private const val PHOTO_SIZE_FACTOR = 0.5f
|
||||||
|
|
||||||
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
|
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
|
||||||
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
|
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
|
||||||
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
|
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
|
||||||
@@ -107,11 +117,11 @@ class AndroidPdfDocumentExporter(
|
|||||||
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
|
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
|
||||||
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
|
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
|
||||||
|
|
||||||
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */
|
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 536px bei 257,5pt/150dpi). */
|
||||||
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt()
|
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
|
||||||
|
|
||||||
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */
|
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 793px bei 381pt/150dpi). */
|
||||||
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt()
|
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
|
||||||
|
|
||||||
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
}
|
}
|
||||||
@@ -208,10 +218,11 @@ class AndroidPdfDocumentExporter(
|
|||||||
textSize = HEADER_INFO_TEXT_SIZE
|
textSize = HEADER_INFO_TEXT_SIZE
|
||||||
color = Color.BLACK
|
color = Color.BLACK
|
||||||
}
|
}
|
||||||
|
// Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv.
|
||||||
val blockTitle = TextPaint().apply {
|
val blockTitle = TextPaint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
textSize = BLOCK_TITLE_TEXT_SIZE
|
textSize = BLOCK_TITLE_TEXT_SIZE
|
||||||
isFakeBoldText = true
|
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
|
||||||
color = Color.BLACK
|
color = Color.BLACK
|
||||||
}
|
}
|
||||||
val item = TextPaint().apply {
|
val item = TextPaint().apply {
|
||||||
@@ -267,8 +278,15 @@ class AndroidPdfDocumentExporter(
|
|||||||
cursor.drawDivider(spacingAfter = 12f)
|
cursor.drawDivider(spacingAfter = 12f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Überschriften erscheinen im PDF immer mit abschließendem Doppelpunkt (kein doppelter
|
||||||
|
* Doppelpunkt, falls der Titel bereits mit einem endet). */
|
||||||
|
private fun headingText(title: String): String {
|
||||||
|
val trimmed = title.trimEnd()
|
||||||
|
return if (trimmed.endsWith(":")) trimmed else "$trimmed:"
|
||||||
|
}
|
||||||
|
|
||||||
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
|
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
|
||||||
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f)
|
cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
|
||||||
|
|
||||||
for (item in block.items) {
|
for (item in block.items) {
|
||||||
drawItem(cursor, item, paints)
|
drawItem(cursor, item, paints)
|
||||||
@@ -281,8 +299,33 @@ class AndroidPdfDocumentExporter(
|
|||||||
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
|
* 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. */
|
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
|
||||||
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
|
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
|
||||||
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
|
// Änderungswunsch: strukturierte Eingabefelder (Zahl/Auswahlliste, z. B. "Von ... bis
|
||||||
val labelText = if (item.isFreeText) "${item.label}:" else item.label
|
// ... 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(
|
cursor.drawText(
|
||||||
text = labelText,
|
text = labelText,
|
||||||
@@ -317,7 +360,7 @@ class AndroidPdfDocumentExporter(
|
|||||||
paints: ExportPaints,
|
paints: ExportPaints,
|
||||||
retainedSignatureBitmaps: MutableList<Bitmap>,
|
retainedSignatureBitmaps: MutableList<Bitmap>,
|
||||||
) {
|
) {
|
||||||
cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
|
cursor.drawText(headingText("Unterschriften"), paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
|
||||||
for (signature in model.signatures) {
|
for (signature in model.signatures) {
|
||||||
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
|
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
|
||||||
}
|
}
|
||||||
@@ -413,7 +456,7 @@ class AndroidPdfDocumentExporter(
|
|||||||
) {
|
) {
|
||||||
cursor.forceNewPage()
|
cursor.forceNewPage()
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f)
|
cursor.drawText(headingText("Fotoanhang"), paints.blockTitle, spacingAfter = 8f)
|
||||||
}
|
}
|
||||||
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
|
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
|
||||||
|
|
||||||
@@ -428,7 +471,7 @@ class AndroidPdfDocumentExporter(
|
|||||||
|
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
retainedPhotoBitmaps.add(bitmap)
|
retainedPhotoBitmaps.add(bitmap)
|
||||||
cursor.drawBitmapFitted(bitmap, paints.photo)
|
cursor.drawBitmapFitted(bitmap, paints.photo, sizeFactor = PHOTO_SIZE_FACTOR)
|
||||||
} else {
|
} else {
|
||||||
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
|
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
|
||||||
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
|
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ object DocumentationToPdfExportModelMapper {
|
|||||||
label = item.label,
|
label = item.label,
|
||||||
isFreeText = item.isFreeText,
|
isFreeText = item.isFreeText,
|
||||||
freeTextValue = item.freeTextValue,
|
freeTextValue = item.freeTextValue,
|
||||||
|
inputSpec = item.inputSpec,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,11 +34,17 @@ data class PdfExportBlock(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
|
/** 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. */
|
* 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(
|
data class PdfExportItem(
|
||||||
val label: String,
|
val label: String,
|
||||||
val isFreeText: Boolean,
|
val isFreeText: Boolean,
|
||||||
val freeTextValue: String,
|
val freeTextValue: String,
|
||||||
|
val inputSpec: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,17 +107,21 @@ internal class PdfPageCursor(
|
|||||||
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
|
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
|
||||||
* hineinskaliert (kleiner, nie abgeschnitten).
|
* hineinskaliert (kleiner, nie abgeschnitten).
|
||||||
*/
|
*/
|
||||||
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) {
|
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint, sizeFactor: Float = 1f) {
|
||||||
startIfNeeded()
|
startIfNeeded()
|
||||||
val availableWidth = contentWidth.toFloat()
|
val availableWidth = contentWidth.toFloat()
|
||||||
val availableHeight = remainingHeight
|
val availableHeight = remainingHeight
|
||||||
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
|
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
|
||||||
|
|
||||||
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height)
|
// [sizeFactor] < 1 (Änderungswunsch: Fotoanhang auf 50 %): das Bild wird zuerst wie
|
||||||
|
// gewohnt in den verfügbaren Platz eingepasst und dann um diesen Faktor verkleinert
|
||||||
|
// (Seitenverhältnis bleibt). Das verkleinerte Bild steht horizontal zentriert direkt
|
||||||
|
// unter der Bildunterschrift (oben ausgerichtet) statt mitten in der Restfläche.
|
||||||
|
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) * sizeFactor
|
||||||
val drawWidth = bitmap.width * scale
|
val drawWidth = bitmap.width * scale
|
||||||
val drawHeight = bitmap.height * scale
|
val drawHeight = bitmap.height * scale
|
||||||
val left = marginLeft + (availableWidth - drawWidth) / 2f
|
val left = marginLeft + (availableWidth - drawWidth) / 2f
|
||||||
val top = cursorY + (availableHeight - drawHeight) / 2f
|
val top = if (sizeFactor < 1f) cursorY else cursorY + (availableHeight - drawHeight) / 2f
|
||||||
|
|
||||||
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
|
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
|
||||||
cursorY = contentBottom()
|
cursorY = contentBottom()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übergibt die fertige PDF-Datei selbst (statt in JPEG-Seiten umgewandelt, s.
|
||||||
|
* [PdfToImagesConverter]/[PdfImageSharer]) an den Android-"Senden an"-Dialog
|
||||||
|
* (`ACTION_SEND`, `application/pdf`) - Änderungswunsch: Nutzer soll beim Antippen des
|
||||||
|
* Teilen-Buttons wählen können, ob als JPG ODER als PDF geteilt wird.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt von [PdfOpener] (öffnet dieselbe Datei stattdessen per `ACTION_VIEW` in
|
||||||
|
* einem PDF-Reader) - unterschiedliche Nutzerabsicht (ansehen vs. weitergeben), auch wenn
|
||||||
|
* beide dieselbe Datei über denselben FileProvider-Pfad freigeben.
|
||||||
|
*/
|
||||||
|
interface PdfSharer {
|
||||||
|
fun share(pdfFile: File): PdfShareResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PdfShareResult {
|
||||||
|
data object Success : PdfShareResult
|
||||||
|
|
||||||
|
sealed interface Failure : PdfShareResult {
|
||||||
|
data class UriResolutionFailed(val message: String) : Failure
|
||||||
|
data object NoAppAvailable : Failure
|
||||||
|
data class Unexpected(val message: String) : Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PdfShareResult.Failure.toUserMessage(): String = when (this) {
|
||||||
|
is PdfShareResult.Failure.UriResolutionFailed ->
|
||||||
|
"Die PDF-Datei konnte nicht zum Versenden freigegeben werden."
|
||||||
|
PdfShareResult.Failure.NoAppAvailable ->
|
||||||
|
"Auf diesem Gerät ist keine App zum Versenden von PDFs installiert."
|
||||||
|
is PdfShareResult.Failure.Unexpected ->
|
||||||
|
"Die PDF-Datei konnte nicht versendet werden."
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidPdfSharer(private val context: Context) : PdfSharer {
|
||||||
|
|
||||||
|
override fun share(pdfFile: File): PdfShareResult {
|
||||||
|
val authority = "${context.packageName}.fileprovider"
|
||||||
|
val uri = try {
|
||||||
|
FileProvider.getUriForFile(context, authority, pdfFile)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
return PdfShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/pdf"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
val chooser = Intent.createChooser(intent, "PDF senden an")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
context.startActivity(chooser)
|
||||||
|
PdfShareResult.Success
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
|
||||||
|
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
|
||||||
|
PdfShareResult.Failure.NoAppAvailable
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
PdfShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PdfShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der PDF-Datei.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert eine bereits fertig erzeugte PDF-Datei (s. [DocumentationPdfExporter]) seitenweise in
|
||||||
|
* einzelne JPEG-Dateien - Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können".
|
||||||
|
*
|
||||||
|
* Bewusst als eigener Schritt NACH dem PDF-Export statt als Alternative dazu: Es gibt weiterhin
|
||||||
|
* genau eine Quelle der Wahrheit für das Seitenlayout (der bestehende [PdfPageCursor]-basierte
|
||||||
|
* Export) - dieser Konverter rastert lediglich das bereits fertige Ergebnis, statt eine zweite,
|
||||||
|
* parallele Layout-Logik zu pflegen.
|
||||||
|
*
|
||||||
|
* Verwendet [android.graphics.pdf.PdfRenderer] (Android-Bordmittel seit API 21, unproblematisch
|
||||||
|
* bei minSdk 26) - keine zusätzliche Bibliothek nötig. Eine mehrseitige Wunddokumentation (Kopf/
|
||||||
|
* Blöcke, Signaturen, ggf. 30+ Fotoseiten) ergibt dabei bewusst EIN JPEG PRO SEITE, kein
|
||||||
|
* einzelnes Sammelbild - beim späteren Teilen (s. [PdfImageSharer]) werden alle Seiten
|
||||||
|
* gemeinsam als Bilder-Mehrfachauswahl übergeben.
|
||||||
|
*/
|
||||||
|
interface PdfToImagesConverter {
|
||||||
|
suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PdfToImagesResult {
|
||||||
|
data class Success(val imageFiles: List<File>) : PdfToImagesResult
|
||||||
|
|
||||||
|
sealed interface Failure : PdfToImagesResult {
|
||||||
|
/** Die übergebene PDF-Datei existiert nicht (mehr). */
|
||||||
|
data object FileNotFound : Failure
|
||||||
|
|
||||||
|
/** PdfRenderer meldet 0 Seiten - sollte bei einem selbst erzeugten PDF nie vorkommen. */
|
||||||
|
data object EmptyDocument : Failure
|
||||||
|
|
||||||
|
data class Unexpected(val message: String) : Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
|
||||||
|
PdfToImagesResult.Failure.FileNotFound ->
|
||||||
|
"Die PDF-Datei wurde nicht gefunden. Bitte zuerst die Dokumentation erneut exportieren."
|
||||||
|
PdfToImagesResult.Failure.EmptyDocument ->
|
||||||
|
"Die PDF-Datei enthält keine Seiten."
|
||||||
|
is PdfToImagesResult.Failure.Unexpected ->
|
||||||
|
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
|
||||||
|
|
||||||
|
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (!pdfFile.exists()) return@withContext PdfToImagesResult.Failure.FileNotFound
|
||||||
|
|
||||||
|
val outputDir = File(File(context.filesDir, "pdf_images"), documentationId)
|
||||||
|
// Bilder eines vorherigen Exports zuerst entfernen, damit bei einer inzwischen
|
||||||
|
// kürzeren PDF (z. B. Fotos gelöscht) keine veralteten, zu vielen Seiten-Dateien
|
||||||
|
// liegen bleiben.
|
||||||
|
outputDir.deleteRecursively()
|
||||||
|
outputDir.mkdirs()
|
||||||
|
|
||||||
|
try {
|
||||||
|
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
|
||||||
|
PdfRenderer(fd).use { renderer ->
|
||||||
|
if (renderer.pageCount == 0) {
|
||||||
|
return@withContext PdfToImagesResult.Failure.EmptyDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
val imageFiles = mutableListOf<File>()
|
||||||
|
for (pageIndex in 0 until renderer.pageCount) {
|
||||||
|
renderer.openPage(pageIndex).use { page ->
|
||||||
|
val scale = RENDER_DPI / POINTS_PER_INCH
|
||||||
|
val width = (page.width * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
val height = (page.height * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
// WICHTIG: PdfRenderer zeichnet nur die tatsächlich im PDF
|
||||||
|
// vorhandenen Inhalte - unbedeckte Flächen bleiben transparent
|
||||||
|
// (Alpha 0). JPEG kennt keine Transparenz; ohne dieses
|
||||||
|
// Vorab-Weißfüllen würde jede unbedeckte Fläche beim
|
||||||
|
// Komprimieren als SCHWARZ statt als weißer Seitenhintergrund
|
||||||
|
// enden (bekannte PdfRenderer-Falle, s. offizielle Android-
|
||||||
|
// Beispiele).
|
||||||
|
bitmap.eraseColor(Color.WHITE)
|
||||||
|
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
|
||||||
|
val imageFile = File(outputDir, "seite_%02d.jpg".format(pageIndex + 1))
|
||||||
|
FileOutputStream(imageFile).use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
imageFiles.add(imageFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PdfToImagesResult.Success(imageFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
|
||||||
|
* ein für Ausdruck/Weitergabe ausreichend scharfes, aber nicht unnötig großes Bild. */
|
||||||
|
private const val RENDER_DPI = 200f
|
||||||
|
private const val POINTS_PER_INCH = 72f
|
||||||
|
private const val JPEG_QUALITY = 90
|
||||||
|
}
|
||||||
|
}
|
||||||
+302
-3
@@ -1,5 +1,6 @@
|
|||||||
package de.medapp.wunddoku.ui.documentation.edit
|
package de.medapp.wunddoku.ui.documentation.edit
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -10,16 +11,19 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -29,7 +33,9 @@ import androidx.compose.material.icons.filled.CheckCircle
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
import androidx.compose.material.icons.filled.PictureAsPdf
|
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
@@ -39,12 +45,16 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedIconButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -53,6 +63,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -64,7 +75,10 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -72,13 +86,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.medapp.wunddoku.R
|
import de.medapp.wunddoku.R
|
||||||
import de.medapp.wunddoku.domain.model.CheckItem
|
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.DocumentationBlock
|
||||||
import de.medapp.wunddoku.domain.model.Photo
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
import de.medapp.wunddoku.domain.model.Signature
|
import de.medapp.wunddoku.domain.model.Signature
|
||||||
|
import de.medapp.wunddoku.pdf.AndroidPdfImageSharer
|
||||||
import de.medapp.wunddoku.pdf.AndroidPdfOpener
|
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.PdfOpenResult
|
||||||
|
import de.medapp.wunddoku.pdf.PdfShareResult
|
||||||
import de.medapp.wunddoku.pdf.toUserMessage
|
import de.medapp.wunddoku.pdf.toUserMessage
|
||||||
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
|
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
||||||
@@ -137,7 +158,54 @@ fun DocumentationEditScreen(
|
|||||||
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
|
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
|
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
|
||||||
|
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
|
||||||
|
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Kamerabutton im Fotobereich (Änderungswunsch): nimmt mehrere Fotos hintereinander auf.
|
||||||
|
// Es wird die System-Kamera-App über ACTION_IMAGE_CAPTURE (TakePicture-Contract) genutzt -
|
||||||
|
// dafür ist KEINE CAMERA-Berechtigung nötig. Nach jeder erfolgreichen Aufnahme öffnet sich
|
||||||
|
// die Kamera automatisch erneut; Abbrechen/Zurück beendet die Serie. Die Aufnahme landet
|
||||||
|
// zunächst in einer temporären Cache-Datei (FileProvider-Pfad "camera/") und wird dann wie
|
||||||
|
// eine Picker-Auswahl in die Dokumentation importiert (Downsampling, EXIF, interne Ablage).
|
||||||
|
// Der Pfad der gerade laufenden Aufnahme ist rememberSaveable, damit er einen Wechsel der
|
||||||
|
// Konfiguration (Drehen) oder Prozesstod während der Kamera-App übersteht.
|
||||||
|
var pendingCameraFilePath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var cameraRelaunchRequested by remember { mutableStateOf(false) }
|
||||||
|
val cameraLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.TakePicture(),
|
||||||
|
) { success: Boolean ->
|
||||||
|
val file = pendingCameraFilePath?.let(::File)
|
||||||
|
pendingCameraFilePath = null
|
||||||
|
if (file != null && success && file.length() > 0L) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
viewModel.onCameraPhotoCaptured(uri) { file.delete() }
|
||||||
|
// Nächste Aufnahme der Serie.
|
||||||
|
cameraRelaunchRequested = true
|
||||||
|
} else {
|
||||||
|
// Abgebrochen (oder leere Datei): Serie beendet, temporäre Datei aufräumen.
|
||||||
|
file?.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun startCamera() {
|
||||||
|
val captureDir = File(context.cacheDir, "camera").apply { mkdirs() }
|
||||||
|
val file = File.createTempFile("capture_", ".jpg", captureDir)
|
||||||
|
pendingCameraFilePath = file.absolutePath
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
try {
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
pendingCameraFilePath = null
|
||||||
|
file.delete()
|
||||||
|
viewModel.onCameraUnavailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(cameraRelaunchRequested) {
|
||||||
|
if (cameraRelaunchRequested) {
|
||||||
|
cameraRelaunchRequested = false
|
||||||
|
startCamera()
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(uiState.pdfExportMessage) {
|
LaunchedEffect(uiState.pdfExportMessage) {
|
||||||
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
||||||
val exportedFile = uiState.lastExportedPdfFile
|
val exportedFile = uiState.lastExportedPdfFile
|
||||||
@@ -157,6 +225,34 @@ fun DocumentationEditScreen(
|
|||||||
viewModel.dismissPdfExportMessage()
|
viewModel.dismissPdfExportMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
|
||||||
|
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s.
|
||||||
|
// DocumentationEditViewModel.shareAsImages), öffnet dieser Effekt den System-
|
||||||
|
// Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
|
||||||
|
LaunchedEffect(uiState.imagesToShare) {
|
||||||
|
val images = uiState.imagesToShare ?: return@LaunchedEffect
|
||||||
|
when (val shareResult = pdfImageSharer.share(images)) {
|
||||||
|
PdfImageShareResult.Success -> Unit
|
||||||
|
is PdfImageShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
|
||||||
|
}
|
||||||
|
viewModel.onImageShareHandled()
|
||||||
|
}
|
||||||
|
// Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s.
|
||||||
|
// DocumentationEditViewModel.shareAsPdf).
|
||||||
|
LaunchedEffect(uiState.pdfToShare) {
|
||||||
|
val pdfFile = uiState.pdfToShare ?: return@LaunchedEffect
|
||||||
|
when (val shareResult = pdfSharer.share(pdfFile)) {
|
||||||
|
PdfShareResult.Success -> Unit
|
||||||
|
is PdfShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
|
||||||
|
}
|
||||||
|
viewModel.onPdfShareHandled()
|
||||||
|
}
|
||||||
|
LaunchedEffect(uiState.shareMessage) {
|
||||||
|
val message = uiState.shareMessage ?: return@LaunchedEffect
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
viewModel.dismissShareMessage()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -175,9 +271,8 @@ fun DocumentationEditScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt
|
// PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
|
||||||
// ausschließlich die Datei im internen Speicher (PDF-001/002), als
|
// erzeugt die Datei im internen Speicher (PDF-001/002).
|
||||||
// minimaler Trigger zum manuellen Testen der Export-Funktionalität.
|
|
||||||
if (uiState.pdfExportInProgress) {
|
if (uiState.pdfExportInProgress) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
@@ -185,6 +280,16 @@ fun DocumentationEditScreen(
|
|||||||
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
|
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
|
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
|
||||||
// Autosave gesichert, kein expliziter Speichern-Button nötig).
|
// Autosave gesichert, kein expliziter Speichern-Button nötig).
|
||||||
Button(onClick = onFinished) { Text("Fertig") }
|
Button(onClick = onFinished) { Text("Fertig") }
|
||||||
@@ -227,6 +332,7 @@ fun DocumentationEditScreen(
|
|||||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onTakePhotosClick = ::startCamera,
|
||||||
onDeletePhotoRequested = viewModel::requestDeletePhoto,
|
onDeletePhotoRequested = viewModel::requestDeletePhoto,
|
||||||
loadThumbnail = viewModel::loadPhotoThumbnail,
|
loadThumbnail = viewModel::loadPhotoThumbnail,
|
||||||
onPhotoClick = { viewedPhoto = it },
|
onPhotoClick = { viewedPhoto = it },
|
||||||
@@ -315,6 +421,52 @@ fun DocumentationEditScreen(
|
|||||||
onDismiss = { viewedPhoto = null },
|
onDismiss = { viewedPhoto = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons.
|
||||||
|
if (uiState.showShareFormatDialog) {
|
||||||
|
ShareFormatDialog(
|
||||||
|
onDismiss = viewModel::dismissShareFormatDialog,
|
||||||
|
onChooseImages = viewModel::shareAsImages,
|
||||||
|
onChoosePdf = viewModel::shareAsPdf,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als
|
||||||
|
* PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird
|
||||||
|
* überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) -
|
||||||
|
* kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ShareFormatDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChooseImages: () -> Unit,
|
||||||
|
onChoosePdf: () -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
|
||||||
|
Text("Teilen als", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Als Bilder (JPG) oder als PDF teilen?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
OutlinedButton(onClick = onChooseImages) { Text("JPG") }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(onClick = onChoosePdf) { Text("PDF") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,6 +492,7 @@ private fun DocumentationEditContent(
|
|||||||
photoImportError: String?,
|
photoImportError: String?,
|
||||||
onDismissPhotoImportError: () -> Unit,
|
onDismissPhotoImportError: () -> Unit,
|
||||||
onAddPhotosClick: () -> Unit,
|
onAddPhotosClick: () -> Unit,
|
||||||
|
onTakePhotosClick: () -> Unit,
|
||||||
onDeletePhotoRequested: (photoId: String) -> Unit,
|
onDeletePhotoRequested: (photoId: String) -> Unit,
|
||||||
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
onPhotoClick: (Photo) -> Unit,
|
onPhotoClick: (Photo) -> Unit,
|
||||||
@@ -347,8 +500,28 @@ private fun DocumentationEditContent(
|
|||||||
onDeleteSignatureRequested: (signatureId: String) -> Unit,
|
onDeleteSignatureRequested: (signatureId: String) -> Unit,
|
||||||
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
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(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
state = listState,
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -416,6 +589,10 @@ private fun DocumentationEditContent(
|
|||||||
if (photoImportInProgress) {
|
if (photoImportInProgress) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
}
|
}
|
||||||
|
// Kamera: mehrere Aufnahmen hintereinander (s. startCamera).
|
||||||
|
OutlinedIconButton(onClick = onTakePhotosClick) {
|
||||||
|
Icon(Icons.Filled.PhotoCamera, contentDescription = "Fotos aufnehmen")
|
||||||
|
}
|
||||||
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
|
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = null)
|
Icon(Icons.Filled.Add, contentDescription = null)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
@@ -678,6 +855,11 @@ private fun BlockAccordionItem(
|
|||||||
for (item in block.items) {
|
for (item in block.items) {
|
||||||
when {
|
when {
|
||||||
item.isStaticHint -> StaticHintRow(item = item)
|
item.isStaticHint -> StaticHintRow(item = item)
|
||||||
|
item.inputSpec.isNotEmpty() -> StructuredInputRow(
|
||||||
|
item = item,
|
||||||
|
onCheckedChanged = onCheckedChanged,
|
||||||
|
onFreeTextChanged = onFreeTextChanged,
|
||||||
|
)
|
||||||
item.isFreeText -> FreeTextCheckItemRow(
|
item.isFreeText -> FreeTextCheckItemRow(
|
||||||
item = item,
|
item = item,
|
||||||
onCheckedChanged = onCheckedChanged,
|
onCheckedChanged = onCheckedChanged,
|
||||||
@@ -751,3 +933,120 @@ private fun StaticHintRow(item: CheckItem) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+207
-25
@@ -15,6 +15,8 @@ import de.medapp.wunddoku.domain.model.Documentation
|
|||||||
import de.medapp.wunddoku.domain.model.WoundCarer
|
import de.medapp.wunddoku.domain.model.WoundCarer
|
||||||
import de.medapp.wunddoku.pdf.PdfExportError
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
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 de.medapp.wunddoku.ui.navigation.Screen
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -24,7 +26,10 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
data class DocumentationEditUiState(
|
data class DocumentationEditUiState(
|
||||||
val documentation: Documentation? = null,
|
val documentation: Documentation? = null,
|
||||||
@@ -63,6 +68,28 @@ data class DocumentationEditUiState(
|
|||||||
* referenziert werden kann, während ein neuer Export läuft.
|
* referenziert werden kann, während ein neuer Export läuft.
|
||||||
*/
|
*/
|
||||||
val lastExportedPdfFile: File? = null,
|
val lastExportedPdfFile: File? = null,
|
||||||
|
/** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer
|
||||||
|
* Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages],
|
||||||
|
* PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert
|
||||||
|
* wird. */
|
||||||
|
val shareInProgress: Boolean = false,
|
||||||
|
/** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - ein erfolgreicher
|
||||||
|
* Export löst dagegen KEINE Meldung aus, sondern direkt [imagesToShare] bzw. [pdfToShare]. */
|
||||||
|
val shareMessage: String? = null,
|
||||||
|
/** true, solange die Auswahl "Als JPG oder PDF teilen?" angezeigt wird (Änderungswunsch:
|
||||||
|
* Format-Auswahl beim Antippen des Teilen-Buttons, bevor irgendetwas exportiert wird). */
|
||||||
|
val showShareFormatDialog: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder
|
||||||
|
* über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect
|
||||||
|
* beobachtet, der daraufhin den System-"Senden an"-Dialog öffnet und anschließend
|
||||||
|
* [DocumentationEditViewModel.onImageShareHandled] aufruft - das Teilen selbst (Intent/
|
||||||
|
* Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
|
||||||
|
*/
|
||||||
|
val imagesToShare: List<File>? = null,
|
||||||
|
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
|
||||||
|
* [DocumentationEditViewModel.onPdfShareHandled]. */
|
||||||
|
val pdfToShare: File? = null,
|
||||||
) {
|
) {
|
||||||
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
||||||
|
|
||||||
@@ -93,6 +120,10 @@ class DocumentationEditViewModel(
|
|||||||
private val woundCarerRepository: WoundCarerRepository,
|
private val woundCarerRepository: WoundCarerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Serialisiert Fotoimporte (s. [importPhotos]) und zählt laufende Importe für den Ladeindikator. */
|
||||||
|
private val photoImportMutex = Mutex()
|
||||||
|
private val pendingPhotoImports = AtomicInteger(0)
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DocumentationEditUiState())
|
private val _uiState = MutableStateFlow(DocumentationEditUiState())
|
||||||
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -200,38 +231,75 @@ class DocumentationEditViewModel(
|
|||||||
* UI konsistent den tatsächlichen Datenbankstand zeigt.
|
* UI konsistent den tatsächlichen Datenbankstand zeigt.
|
||||||
*/
|
*/
|
||||||
fun onPhotosPicked(uris: List<Uri>) {
|
fun onPhotosPicked(uris: List<Uri>) {
|
||||||
if (uris.isEmpty()) return
|
importPhotos(uris, cleanup = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt ein mit der Kamera aufgenommenes Foto (Kamerabutton im Fotobereich, s.
|
||||||
|
* DocumentationEditScreen). Läuft über denselben Importweg wie die Photo-Picker-Auswahl
|
||||||
|
* (Downsampling, EXIF-Korrektur, interne Ablage). [cleanup] löscht anschließend die
|
||||||
|
* temporäre Aufnahmedatei im Cache - unabhängig davon, ob der Import gelungen ist.
|
||||||
|
*/
|
||||||
|
fun onCameraPhotoCaptured(uri: Uri, cleanup: () -> Unit) {
|
||||||
|
importPhotos(listOf(uri), cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsamer Importweg für Picker-Auswahl und Kameraaufnahmen. Importe laufen über einen
|
||||||
|
* [photoImportMutex] strikt nacheinander: Bei einer Kameraserie kann die nächste Aufnahme
|
||||||
|
* eintreffen, während die vorige noch importiert wird - ohne Sperre könnten zwei Importe
|
||||||
|
* dieselbe nächste orderIndex vergeben.
|
||||||
|
*/
|
||||||
|
private fun importPhotos(uris: List<Uri>, cleanup: () -> Unit) {
|
||||||
|
if (uris.isEmpty()) {
|
||||||
|
runCatching { cleanup() }
|
||||||
|
return
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
|
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
|
||||||
var failureCount = 0
|
pendingPhotoImports.incrementAndGet()
|
||||||
for (uri in uris) {
|
try {
|
||||||
try {
|
photoImportMutex.withLock {
|
||||||
repository.addPhoto(documentationId, uri)
|
var failureCount = 0
|
||||||
} catch (e: Exception) {
|
for (uri in uris) {
|
||||||
failureCount++
|
try {
|
||||||
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
|
repository.addPhoto(documentationId, uri)
|
||||||
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
} catch (e: Exception) {
|
||||||
android.util.Log.w(
|
failureCount++
|
||||||
"DocumentationEdit",
|
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
|
||||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
|
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
||||||
)
|
android.util.Log.w(
|
||||||
|
"DocumentationEdit",
|
||||||
|
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val refreshed = repository.getFullDocumentation(documentationId)
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
documentation = refreshed ?: state.documentation,
|
||||||
|
photoImportError = if (failureCount > 0) {
|
||||||
|
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
|
||||||
|
} else {
|
||||||
|
state.photoImportError
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runCatching { cleanup() }
|
||||||
|
if (pendingPhotoImports.decrementAndGet() == 0) {
|
||||||
|
_uiState.update { it.copy(photoImportInProgress = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val refreshed = repository.getFullDocumentation(documentationId)
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
documentation = refreshed ?: state.documentation,
|
|
||||||
photoImportInProgress = false,
|
|
||||||
photoImportError = if (failureCount > 0) {
|
|
||||||
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Meldet, dass die Kamera auf dem Gerät nicht gestartet werden konnte (keine Kamera-App). */
|
||||||
|
fun onCameraUnavailable() {
|
||||||
|
_uiState.update { it.copy(photoImportError = "Es konnte keine Kamera-App gestartet werden.") }
|
||||||
|
}
|
||||||
|
|
||||||
fun dismissPhotoImportError() {
|
fun dismissPhotoImportError() {
|
||||||
_uiState.update { it.copy(photoImportError = null) }
|
_uiState.update { it.copy(photoImportError = null) }
|
||||||
}
|
}
|
||||||
@@ -357,6 +425,120 @@ class DocumentationEditViewModel(
|
|||||||
_uiState.update { it.copy(pdfExportMessage = null) }
|
_uiState.update { it.copy(pdfExportMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet die Auswahl "Als JPG oder PDF teilen?" (Änderungswunsch: Format-Auswahl beim
|
||||||
|
* Antippen des Teilen-Buttons, BEVOR irgendetwas exportiert wird - vorher wurde beim
|
||||||
|
* Antippen sofort und ausschließlich als JPG geteilt).
|
||||||
|
*/
|
||||||
|
fun requestShareFormatChoice() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissShareFormatDialog() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nutzer hat sich für "Als JPG teilen" entschieden. */
|
||||||
|
fun shareAsImages() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = false) }
|
||||||
|
exportAndShareAsImages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nutzer hat sich für "Als PDF teilen" entschieden. */
|
||||||
|
fun shareAsPdf() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = false) }
|
||||||
|
exportAndSharePdf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportiert die Dokumentation frisch als PDF und wandelt das Ergebnis anschließend
|
||||||
|
* seitenweise in JPEG-Bilder um (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden
|
||||||
|
* können"). Bewusst ein eigener, vollständiger Export-Lauf statt Wiederverwendung von
|
||||||
|
* [uiState.lastExportedPdfFile]: so spiegeln die Bilder IMMER den aktuellen Stand wider,
|
||||||
|
* unabhängig davon, ob/wann zuvor schon einmal "PDF erzeugen" angetippt wurde.
|
||||||
|
*
|
||||||
|
* Das eigentliche Teilen (Android-"Senden an"-Dialog) übernimmt NICHT diese Methode, sondern
|
||||||
|
* der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
|
||||||
|
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
|
||||||
|
*/
|
||||||
|
private fun exportAndShareAsImages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val exportResult = repository.exportPdf(documentationId)
|
||||||
|
val pdfFile = when (exportResult) {
|
||||||
|
is PdfExportResult.Success -> exportResult.file
|
||||||
|
is PdfExportResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
shareInProgress = false,
|
||||||
|
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
|
||||||
|
is PdfToImagesResult.Success -> _uiState.update {
|
||||||
|
it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PdfToImagesResult.Failure -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
shareInProgress = false,
|
||||||
|
shareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportiert die Dokumentation frisch als PDF und stellt sie zum Teilen bereit
|
||||||
|
* (Änderungswunsch: Format-Auswahl "PDF" statt "JPG") - ohne die JPEG-Konvertierung, die
|
||||||
|
* geteilte Datei ist die PDF selbst. Das eigentliche Teilen übernimmt der Screen anhand von
|
||||||
|
* [DocumentationEditUiState.pdfToShare] (s. [de.medapp.wunddoku.pdf.PdfSharer]).
|
||||||
|
*/
|
||||||
|
private fun exportAndSharePdf() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(shareInProgress = true, shareMessage = null, pdfToShare = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val exportResult = repository.exportPdf(documentationId)) {
|
||||||
|
is PdfExportResult.Success -> _uiState.update {
|
||||||
|
it.copy(shareInProgress = false, pdfToShare = exportResult.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PdfExportResult.Failure -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
shareInProgress = false,
|
||||||
|
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wird vom Screen aufgerufen, nachdem der "Senden an"-Dialog für [DocumentationEditUiState.imagesToShare]
|
||||||
|
* geöffnet wurde (erfolgreich oder nicht) - verhindert ein erneutes Öffnen bei der nächsten
|
||||||
|
* Recomposition, solange keine neuen Bilder erzeugt wurden. */
|
||||||
|
fun onImageShareHandled() {
|
||||||
|
_uiState.update { it.copy(imagesToShare = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */
|
||||||
|
fun onPdfShareHandled() {
|
||||||
|
_uiState.update { it.copy(pdfToShare = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissShareMessage() {
|
||||||
|
_uiState.update { it.copy(shareMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun PdfExportError.toUserMessage(): String = when (this) {
|
private fun PdfExportError.toUserMessage(): String = when (this) {
|
||||||
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
|
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
|
||||||
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
|
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
|
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
|
||||||
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
|
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
|
||||||
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
|
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
|
||||||
intern (Abschnitt "Storage-Strategie" der Architektur, v3).
|
intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
|
||||||
-->
|
-->
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<files-path name="pdf_exports" path="pdf/" />
|
<files-path name="pdf_exports" path="pdf/" />
|
||||||
|
<files-path name="pdf_images" path="pdf_images/" />
|
||||||
<files-path name="photos" path="photos/" />
|
<files-path name="photos" path="photos/" />
|
||||||
<files-path name="signatures" path="signatures/" />
|
<files-path name="signatures" path="signatures/" />
|
||||||
|
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
|
||||||
|
Dokumentation, danach sofort gelöscht. -->
|
||||||
|
<cache-path name="camera_captures" path="camera/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package de.medapp.wunddoku.domain.usecase
|
||||||
|
|
||||||
|
import de.medapp.wunddoku.domain.model.CheckItem
|
||||||
|
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blockstatus-Tests: Wann gilt ein Dokumentationsblock als "bearbeitet"?
|
||||||
|
* Diese Regel steuert Fortschrittsanzeige UND PDF-Export und muss deshalb exakt stimmen.
|
||||||
|
*/
|
||||||
|
class BlockEditedPolicyTest {
|
||||||
|
|
||||||
|
private var idCounter = 0
|
||||||
|
|
||||||
|
private fun item(
|
||||||
|
checked: Boolean,
|
||||||
|
isFreeText: Boolean = false,
|
||||||
|
freeTextValue: String = "",
|
||||||
|
isStaticHint: Boolean = false,
|
||||||
|
inputSpec: String = "",
|
||||||
|
): CheckItem {
|
||||||
|
idCounter++
|
||||||
|
return CheckItem(
|
||||||
|
checkItemStateId = "state-$idCounter",
|
||||||
|
definitionId = "def-$idCounter",
|
||||||
|
label = "Eintrag $idCounter",
|
||||||
|
order = idCounter,
|
||||||
|
checked = checked,
|
||||||
|
isFreeText = isFreeText,
|
||||||
|
freeTextValue = freeTextValue,
|
||||||
|
isStaticHint = isStaticHint,
|
||||||
|
inputSpec = inputSpec,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun block(vararg items: CheckItem) = DocumentationBlock(
|
||||||
|
blockStateId = "block-state",
|
||||||
|
definitionId = "block-def",
|
||||||
|
title = "Testblock",
|
||||||
|
order = 1,
|
||||||
|
items = items.toList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- nicht bearbeitet ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun block_ohne_eintraege_ist_nicht_bearbeitet() {
|
||||||
|
assertFalse(BlockEditedPolicy.isEdited(block()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun block_ohne_angehakten_eintrag_ist_nicht_bearbeitet() {
|
||||||
|
val b = block(item(checked = false), item(checked = false), item(checked = false, isFreeText = true))
|
||||||
|
assertFalse(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nicht_angehakter_freitext_mit_text_zaehlt_nicht_als_bearbeitung() {
|
||||||
|
// Das Freitextfeld ist in der UI gesperrt, solange die Checkbox nicht angehakt ist.
|
||||||
|
val b = block(item(checked = false, isFreeText = true, freeTextValue = "Text ohne Haken"))
|
||||||
|
assertFalse(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nur_hinweistexte_machen_einen_block_nicht_bearbeitet() {
|
||||||
|
// Hinweistexte sind technisch immer checked = true, aber keine Nutzereingabe.
|
||||||
|
val b = block(
|
||||||
|
item(checked = true, isStaticHint = true),
|
||||||
|
item(checked = true, isStaticHint = true),
|
||||||
|
)
|
||||||
|
assertFalse(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hinweistexte_plus_nicht_angehakte_eintraege_sind_nicht_bearbeitet() {
|
||||||
|
val b = block(
|
||||||
|
item(checked = true, isStaticHint = true),
|
||||||
|
item(checked = false),
|
||||||
|
item(checked = false, isFreeText = true),
|
||||||
|
)
|
||||||
|
assertFalse(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bearbeitet ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ein_angehakter_normaler_eintrag_macht_den_block_bearbeitet() {
|
||||||
|
assertTrue(BlockEditedPolicy.isEdited(block(item(checked = true))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun angehakter_freitext_zaehlt_auch_ohne_eingegebenen_text() {
|
||||||
|
val b = block(item(checked = true, isFreeText = true, freeTextValue = ""))
|
||||||
|
assertTrue(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun angehakter_freitext_mit_text_ist_bearbeitet() {
|
||||||
|
val b = block(item(checked = true, isFreeText = true, freeTextValue = "Rötung am Wundrand"))
|
||||||
|
assertTrue(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun angehakter_eintrag_mit_strukturierter_eingabe_ist_bearbeitet() {
|
||||||
|
val b = block(item(checked = true, freeTextValue = "3", inputSpec = "spec"))
|
||||||
|
assertTrue(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hinweistext_wird_ignoriert_sobald_ein_echter_eintrag_angehakt_ist() {
|
||||||
|
val b = block(
|
||||||
|
item(checked = true, isStaticHint = true),
|
||||||
|
item(checked = true),
|
||||||
|
)
|
||||||
|
assertTrue(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mehrere_angehakte_eintraege_sind_bearbeitet() {
|
||||||
|
val b = block(item(checked = true), item(checked = true), item(checked = false))
|
||||||
|
assertTrue(BlockEditedPolicy.isEdited(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zusammenspiel mit DocumentationBlock.isEdited ------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun documentation_block_isEdited_delegiert_an_die_policy() {
|
||||||
|
val edited = block(item(checked = true))
|
||||||
|
val untouched = block(item(checked = false), item(checked = true, isStaticHint = true))
|
||||||
|
|
||||||
|
assertEquals(BlockEditedPolicy.isEdited(edited), edited.isEdited)
|
||||||
|
assertEquals(BlockEditedPolicy.isEdited(untouched), untouched.isEdited)
|
||||||
|
assertTrue(edited.isEdited)
|
||||||
|
assertFalse(untouched.isEdited)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isEdited_wird_aus_dem_aktuellen_inhalt_abgeleitet_und_nicht_gespeichert() {
|
||||||
|
val untouched = block(item(checked = false))
|
||||||
|
assertFalse(untouched.isEdited)
|
||||||
|
|
||||||
|
val nowChecked = untouched.copy(items = untouched.items.map { it.copy(checked = true) })
|
||||||
|
assertTrue(nowChecked.isEdited)
|
||||||
|
|
||||||
|
val uncheckedAgain = nowChecked.copy(items = nowChecked.items.map { it.copy(checked = false) })
|
||||||
|
assertFalse(uncheckedAgain.isEdited)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import de.medapp.wunddoku.domain.model.CheckItem
|
||||||
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
|
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||||
|
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||||
|
import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot
|
||||||
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
|
import de.medapp.wunddoku.domain.model.Signature
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blockstatus im PDF-Export: Nur bearbeitete Blöcke und nur angehakte Optionen gelangen ins PDF,
|
||||||
|
* alles wird aus den historischen Snapshots gebildet und stabil sortiert.
|
||||||
|
*/
|
||||||
|
class DocumentationToPdfExportModelMapperTest {
|
||||||
|
|
||||||
|
private val snapshot = PatientStammdatenSnapshot(
|
||||||
|
firstName = "Max",
|
||||||
|
lastName = "Mustermann",
|
||||||
|
birthDate = LocalDate.of(1980, 3, 14),
|
||||||
|
street = "Musterstraße 1",
|
||||||
|
postalCode = "12345",
|
||||||
|
city = "Musterstadt",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mittag in der Systemzeitzone -> das lokale Datum ist zeitzonenunabhängig der 23.08.2026.
|
||||||
|
private val createdAt: Instant =
|
||||||
|
LocalDate.of(2026, 8, 23).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant()
|
||||||
|
|
||||||
|
private fun item(
|
||||||
|
id: String,
|
||||||
|
order: Int,
|
||||||
|
checked: Boolean,
|
||||||
|
label: String = "Label $id",
|
||||||
|
isFreeText: Boolean = false,
|
||||||
|
freeTextValue: String = "",
|
||||||
|
isStaticHint: Boolean = false,
|
||||||
|
inputSpec: String = "",
|
||||||
|
) = CheckItem(
|
||||||
|
checkItemStateId = "state-$id",
|
||||||
|
definitionId = id,
|
||||||
|
label = label,
|
||||||
|
order = order,
|
||||||
|
checked = checked,
|
||||||
|
isFreeText = isFreeText,
|
||||||
|
freeTextValue = freeTextValue,
|
||||||
|
isStaticHint = isStaticHint,
|
||||||
|
inputSpec = inputSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun block(id: String, order: Int, title: String = "Titel $id", items: List<CheckItem>) =
|
||||||
|
DocumentationBlock("state-$id", id, title, order, items)
|
||||||
|
|
||||||
|
private fun documentation(
|
||||||
|
blocks: List<DocumentationBlock>,
|
||||||
|
photos: List<Photo> = emptyList(),
|
||||||
|
signatures: List<Signature> = emptyList(),
|
||||||
|
) = Documentation(
|
||||||
|
id = "doc-1",
|
||||||
|
patientId = "patient-1",
|
||||||
|
patientSnapshot = snapshot,
|
||||||
|
status = DocumentationStatus.IN_PROGRESS,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = createdAt,
|
||||||
|
continuedFromDocumentationId = null,
|
||||||
|
catalogVersion = 4,
|
||||||
|
blocks = blocks,
|
||||||
|
photos = photos,
|
||||||
|
signatures = signatures,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Blockfilter ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nur_bearbeitete_bloecke_werden_exportiert() {
|
||||||
|
val doc = documentation(
|
||||||
|
listOf(
|
||||||
|
block("a", 1, items = listOf(item("a1", 1, checked = true))),
|
||||||
|
block("b", 2, items = listOf(item("b1", 1, checked = false))),
|
||||||
|
block("c", 3, items = listOf(item("c1", 1, checked = true))),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val model = DocumentationToPdfExportModelMapper.map(doc)
|
||||||
|
|
||||||
|
assertEquals(listOf("Titel a", "Titel c"), model.blocks.map { it.title })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun block_mit_nur_hinweistexten_wird_nicht_exportiert() {
|
||||||
|
val doc = documentation(
|
||||||
|
listOf(
|
||||||
|
block(
|
||||||
|
"hint", 1,
|
||||||
|
items = listOf(
|
||||||
|
item("h1", 1, checked = true, isStaticHint = true),
|
||||||
|
item("h2", 2, checked = false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dokumentation_ohne_bearbeiteten_block_ergibt_leere_blockliste() {
|
||||||
|
val doc = documentation(listOf(block("a", 1, items = listOf(item("a1", 1, checked = false)))))
|
||||||
|
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Itemfilter ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nur_angehakte_optionen_erscheinen_im_pdf() {
|
||||||
|
val doc = documentation(
|
||||||
|
listOf(
|
||||||
|
block(
|
||||||
|
"a", 1,
|
||||||
|
items = listOf(
|
||||||
|
item("a1", 1, checked = true, label = "Ja"),
|
||||||
|
item("a2", 2, checked = false, label = "Nein"),
|
||||||
|
item("a3", 3, checked = true, label = "Auch ja"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
|
||||||
|
|
||||||
|
assertEquals(listOf("Ja", "Auch ja"), items.map { it.label })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hinweistexte_eines_bearbeiteten_blocks_bleiben_im_pdf_erhalten() {
|
||||||
|
val doc = documentation(
|
||||||
|
listOf(
|
||||||
|
block(
|
||||||
|
"a", 1,
|
||||||
|
items = listOf(
|
||||||
|
item("hint", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
|
||||||
|
item("a2", 2, checked = true, label = "Ringer-Lösung"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
|
||||||
|
|
||||||
|
assertEquals(listOf("Reinigen mit:", "Ringer-Lösung"), items.map { it.label })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun freitext_und_strukturierte_eingabe_werden_unveraendert_durchgereicht() {
|
||||||
|
val doc = documentation(
|
||||||
|
listOf(
|
||||||
|
block(
|
||||||
|
"a", 1,
|
||||||
|
items = listOf(
|
||||||
|
item("f", 1, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "Rötung"),
|
||||||
|
item("s", 2, checked = true, label = "Maße", freeTextValue = "3|4", inputSpec = "SPEC"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
|
||||||
|
|
||||||
|
assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), items[0])
|
||||||
|
assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sortierung ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bloecke_und_optionen_werden_nach_order_sortiert() {
|
||||||
|
val doc = documentation(
|
||||||
|
listOf(
|
||||||
|
block("zweiter", 2, items = listOf(item("z1", 1, checked = true))),
|
||||||
|
block(
|
||||||
|
"erster", 1,
|
||||||
|
items = listOf(
|
||||||
|
item("e2", 2, checked = true, label = "B"),
|
||||||
|
item("e1", 1, checked = true, label = "A"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
|
||||||
|
|
||||||
|
assertEquals(listOf("Titel erster", "Titel zweiter"), blocks.map { it.title })
|
||||||
|
assertEquals(listOf("A", "B"), blocks.first().items.map { it.label })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fotos_und_signaturen_werden_nach_orderIndex_sortiert() {
|
||||||
|
val photos = listOf(
|
||||||
|
Photo("p2", "doc-1", "zweites.jpg", 1, createdAt),
|
||||||
|
Photo("p1", "doc-1", "erstes.jpg", 0, createdAt),
|
||||||
|
)
|
||||||
|
val signatures = listOf(
|
||||||
|
Signature("s2", "doc-1", "Berta", "b.png", 1, createdAt),
|
||||||
|
Signature("s1", "doc-1", "Anna", "a.png", 0, createdAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), photos, signatures))
|
||||||
|
|
||||||
|
assertEquals(listOf("erstes.jpg", "zweites.jpg"), model.photos.map { it.fileName })
|
||||||
|
assertEquals(listOf("Anna", "Berta"), model.signatures.map { it.signerName })
|
||||||
|
assertEquals(listOf("a.png", "b.png"), model.signatures.map { it.fileName })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun alle_signaturen_werden_exportiert_ohne_obergrenze() {
|
||||||
|
val signatures = (0 until 5).map { Signature("s$it", "doc-1", "Person $it", "s$it.png", it, createdAt) }
|
||||||
|
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), signatures = signatures))
|
||||||
|
assertEquals(5, model.signatures.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kopfdaten (Snapshot) ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kopfdaten_stammen_aus_dem_patienten_snapshot_der_dokumentation() {
|
||||||
|
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
|
||||||
|
|
||||||
|
assertEquals("doc-1", model.documentationId)
|
||||||
|
assertEquals("Max Mustermann", model.patientFullName)
|
||||||
|
assertEquals(LocalDate.of(1980, 3, 14), model.patientBirthDate)
|
||||||
|
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
|
||||||
|
assertEquals(LocalDate.of(2026, 8, 23), model.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dateiname_folgt_dem_schema_und_nutzt_snapshot_und_erstelldatum() {
|
||||||
|
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
|
||||||
|
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", model.fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine JVM-Tests (keine Android-Abhängigkeit) für das Dateinamensschema
|
||||||
|
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`.
|
||||||
|
*/
|
||||||
|
class PdfFileNameGeneratorTest {
|
||||||
|
|
||||||
|
private val created = LocalDate.of(2026, 8, 23)
|
||||||
|
private val birth = LocalDate.of(1980, 3, 14)
|
||||||
|
|
||||||
|
private fun name(
|
||||||
|
first: String,
|
||||||
|
last: String,
|
||||||
|
createdAt: LocalDate = created,
|
||||||
|
birthDate: LocalDate = birth,
|
||||||
|
): String = PdfFileNameGenerator.generate(createdAt, first, last, birthDate)
|
||||||
|
|
||||||
|
// --- Grundschema ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun standardfall_folgt_dem_vereinbarten_schema() {
|
||||||
|
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max", "Mustermann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun datumsteile_werden_mit_fuehrenden_nullen_im_iso_format_ausgegeben() {
|
||||||
|
val result = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 1, 5), birthDate = LocalDate.of(1999, 2, 3))
|
||||||
|
assertEquals("2026-01-05_Max_Mustermann_1999-02-03.pdf", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun schaltjahr_geburtsdatum_wird_korrekt_ausgegeben() {
|
||||||
|
val result = name("Max", "Mustermann", birthDate = LocalDate.of(2000, 2, 29))
|
||||||
|
assertEquals("2026-08-23_Max_Mustermann_2000-02-29.pdf", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ergebnis_ist_deterministisch() {
|
||||||
|
assertEquals(name("Max", "Mustermann"), name("Max", "Mustermann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verschiedene_erstelldaten_fuehren_zu_verschiedenen_dateinamen() {
|
||||||
|
val a = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 23))
|
||||||
|
val b = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 24))
|
||||||
|
assertNotEquals(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Umlaute / Diakritika ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deutsche_umlaute_werden_transliteriert_statt_entfernt() {
|
||||||
|
assertEquals("2026-08-23_Max_Mueller_1980-03-14.pdf", name("Max", "Müller"))
|
||||||
|
assertEquals("2026-08-23_Aenne_Oezdemir_1980-03-14.pdf", name("Änne", "Özdemir"))
|
||||||
|
assertEquals("2026-08-23_Uwe_Ueberall_1980-03-14.pdf", name("Uwe", "Überall"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eszett_wird_zu_doppel_s() {
|
||||||
|
assertEquals("2026-08-23_Max_Weiss_1980-03-14.pdf", name("Max", "Weiß"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mueller_und_mller_bleiben_unterscheidbar() {
|
||||||
|
assertNotEquals(name("Max", "Müller"), name("Max", "Mller"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun andere_diakritika_werden_entfernt() {
|
||||||
|
assertEquals("2026-08-23_Jose_Celik_1980-03-14.pdf", name("José", "Çelik"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Leerzeichen / Sonderzeichen -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leerzeichen_werden_zu_unterstrichen() {
|
||||||
|
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mehrfaches_whitespace_wird_zu_einem_unterstrich_zusammengefasst() {
|
||||||
|
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
|
||||||
|
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna\t\nMaria", "Mustermann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mehrfache_unterstriche_werden_zusammengefasst() {
|
||||||
|
assertEquals("2026-08-23_Max_Muster_mann_1980-03-14.pdf", name("Max", "Muster__mann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fuehrende_und_nachgestellte_trennzeichen_werden_entfernt() {
|
||||||
|
val expected = "2026-08-23_Max_Mustermann_1980-03-14.pdf"
|
||||||
|
assertEquals(expected, name(" Max ", "Mustermann"))
|
||||||
|
assertEquals(expected, name("_Max_", "Mustermann"))
|
||||||
|
assertEquals(expected, name("-Max-", "Mustermann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bindestrich_im_doppelnamen_bleibt_erhalten() {
|
||||||
|
assertEquals("2026-08-23_Aenne-Marie_Meyer-Luedenscheidt_1980-03-14.pdf", name("Änne-Marie", "Meyer-Lüdenscheidt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun apostroph_und_punkt_werden_entfernt() {
|
||||||
|
assertEquals("2026-08-23_Sean_OBrien_1980-03-14.pdf", name("Sean", "O'Brien"))
|
||||||
|
assertEquals("2026-08-23_Max_Dr_Mustermann_1980-03-14.pdf", name("Max", "Dr. Mustermann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sicherheit (Pfad-/Dateisystemzeichen) --------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pfadseparatoren_und_punkte_koennen_keinen_pfad_erzeugen() {
|
||||||
|
assertEquals(
|
||||||
|
"2026-08-23_Unbekannt_etcpasswd_1980-03-14.pdf",
|
||||||
|
name("../..", "../etc/passwd"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fuer_dateinamen_ungueltige_zeichen_werden_entfernt() {
|
||||||
|
assertEquals(
|
||||||
|
"2026-08-23_MaxMuster_Mustermann_1980-03-14.pdf",
|
||||||
|
name("Max:Mus*ter?", "Mustermann"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"2026-08-23_Max_Mustermann_1980-03-14.pdf",
|
||||||
|
name("Ma<x>|\"", "Muster\\mann"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emojis_werden_entfernt() {
|
||||||
|
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max😀", "Mustermann"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dateiname_besteht_immer_nur_aus_sicheren_zeichen_und_endet_auf_pdf() {
|
||||||
|
val inputs = listOf(
|
||||||
|
"Max" to "Mustermann",
|
||||||
|
"Änne-Marie" to "Özdemir Weiß",
|
||||||
|
"José" to "O'Brien",
|
||||||
|
"../../x" to "a\\b/c",
|
||||||
|
" " to "",
|
||||||
|
"😀" to "???",
|
||||||
|
"Ma<x>|\"" to "Muster\\mann",
|
||||||
|
)
|
||||||
|
val allowed = Regex("^[A-Za-z0-9_.-]+$")
|
||||||
|
for ((first, last) in inputs) {
|
||||||
|
val result = name(first, last)
|
||||||
|
assertTrue("Unsicherer Dateiname: $result", allowed.matches(result))
|
||||||
|
assertTrue("Falsche Endung: $result", result.endsWith(".pdf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Platzhalter für leere Namen -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leerer_oder_blanker_name_wird_zu_unbekannt() {
|
||||||
|
assertEquals("2026-08-23_Unbekannt_Mustermann_1980-03-14.pdf", name("", "Mustermann"))
|
||||||
|
assertEquals("2026-08-23_Max_Unbekannt_1980-03-14.pdf", name("Max", " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun name_nur_aus_ungueltigen_zeichen_wird_zu_unbekannt() {
|
||||||
|
assertEquals("2026-08-23_Unbekannt_Unbekannt_1980-03-14.pdf", name("???", "***"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user