Compare commits

...

15 Commits

Author SHA1 Message Date
niklas.ortmann 075905c6e0 Update DocumentationCatalog.kt 2026-09-16 10:08:29 +02:00
niklas.ortmann 7948d2c3c6 Box-Inhalte
(müssen mit Kunden besprochen werden)
2026-09-09 08:44:07 +02:00
niklas.ortmann 2ddc1d960a Merge pull request 'Kundenwuensche' (#6) from dev-klock into dev
Reviewed-on: #6
2026-09-07 11:42:06 +02:00
niklas.ortmann 32d23f5d52 Kundenwuensche
Nur angehakte Checkboxen im PDF
Fotos in Originalgröße per Antippen
Freitext zuerst in jedem Block
Akkordeon-Umbau (angefangen)
2026-09-07 09:46:22 +02:00
niklas.ortmann cda0b4d3fe Merge pull request 'PDF/MEDIA' (#5) from dev-PDF into dev
Reviewed-on: #5
2026-09-06 21:20:29 +02:00
niklas.ortmann c52229b7f7 PDF/MEDIA
MEDIA-003
MEDIA-004
PDF-003
2026-09-06 21:19:58 +02:00
niklas.ortmann 91e68c827a PDF-004 2026-08-27 08:10:18 +02:00
niklas.ortmann 8c05a513fe PDF-005 2026-08-25 09:50:31 +02:00
niklas.ortmann ebf85df188 PDF-001 & PDF-002 (ungetestet) 2026-08-25 01:27:29 +02:00
niklas.ortmann 81178cbbc2 Merge pull request 'MEDIA-001 & MEDIA-002' (#4) from dev-MEDIA-001 into dev
Reviewed-on: #4
2026-08-23 23:00:00 +02:00
niklas.ortmann 139dd9f9ec Update DocumentationRepository.kt 2026-08-23 22:59:10 +02:00
niklas.ortmann 50af2a753e Update .gitignore 2026-08-23 22:59:07 +02:00
niklas.ortmann 4ffa6db59b bugfix 2026-08-23 22:54:00 +02:00
niklas.ortmann 9cb49dda5c MEDIA-001 & MEDIA-002 2026-08-23 22:45:13 +02:00
niklas.ortmann 84f3ba40e6 Merge pull request 'Grundlegende Funktionalitäten (Formulare, Speichern, Durchsuchen, etc.)' (#3) from dev-FUNC into dev
Reviewed-on: #3
2026-08-22 11:12:07 +02:00
37 changed files with 5784 additions and 198 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ local.properties
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Android profiling # Android proFiling
*.hprof *.hprof
# Signing keys # Signing keys
@@ -0,0 +1,585 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "cd000019287c8c50d6b8d1b239aa12f0",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd000019287c8c50d6b8d1b239aa12f0')"
]
}
}
@@ -0,0 +1,623 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "182fcff50c0191c9414187dd7b7cacd2",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '182fcff50c0191c9414187dd7b7cacd2')"
]
}
}
@@ -0,0 +1,629 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "12448571f5ffcf00398fa1d8260c9b2b",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isStaticHint",
"columnName": "isStaticHint",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12448571f5ffcf00398fa1d8260c9b2b')"
]
}
}
@@ -18,6 +18,13 @@ interface SignatureDao {
@Query("SELECT * FROM signatures WHERE id = :id") @Query("SELECT * FROM signatures WHERE id = :id")
suspend fun getById(id: String): SignatureEntity? suspend fun getById(id: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId") /** Bereits stabil nach [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex] sortiert
* - dieselbe Reihenfolge, in der Signaturen später auch im PDF erscheinen (PDF-003). */
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId ORDER BY orderIndex")
suspend fun getForDocumentation(documentationId: String): List<SignatureEntity> suspend fun getForDocumentation(documentationId: String): List<SignatureEntity>
/** Für die Vergabe des nächsten [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex]
* beim Anlegen einer neuen Signatur - exakt analog [PhotoDao.getMaxOrderIndex]. */
@Query("SELECT COALESCE(MAX(orderIndex), -1) FROM signatures WHERE documentationId = :documentationId")
suspend fun getMaxOrderIndex(documentationId: String): Int
} }
@@ -0,0 +1,33 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import de.medapp.wunddoku.data.entity.WoundCarerEntity
@Dao
interface WoundCarerDao {
/** Ersetzt einen vorhandenen Eintrag mit derselben [WoundCarerEntity.id] vollständig (Upsert
* per Primärschlüssel) - siehe [de.medapp.wunddoku.data.repository.WoundCarerRepository]
* (Find-by-Name + Wiederverwendung der gefundenen ID) für die eigentliche
* "Neuanlegen-oder-Aktualisieren"-Logik anhand des Namens. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(entity: WoundCarerEntity)
/** Case-insensitiver Exakt-Abgleich - entscheidet, ob unter diesem (getrimmten) Namen
* bereits ein Wundversorger bekannt ist (dann: Signatur aktualisieren) oder ein neuer Eintrag
* angelegt werden muss. */
@Query("SELECT * FROM wound_carers WHERE name = :name COLLATE NOCASE LIMIT 1")
suspend fun findByName(name: String): WoundCarerEntity?
/** Namensvorschläge nach den ersten eingegebenen Buchstaben (Präfix, case-insensitiv),
* neueste Verwendung zuerst. */
@Query("SELECT * FROM wound_carers WHERE name LIKE :prefix || '%' COLLATE NOCASE ORDER BY updatedAt DESC LIMIT :limit")
suspend fun searchByNamePrefix(prefix: String, limit: Int): List<WoundCarerEntity>
/** Für die Vorbelegung des Signatur-Dialogs mit dem zuletzt verwendeten Wundversorger. */
@Query("SELECT * FROM wound_carers ORDER BY updatedAt DESC LIMIT 1")
suspend fun getMostRecentlyUsed(): WoundCarerEntity?
}
@@ -11,12 +11,14 @@ import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.entity.BlockStateEntity import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
/** /**
@@ -26,18 +28,26 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
* Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung - * Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung -
* Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von * Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von
* CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf * CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf
* DocumentationEntity. * DocumentationEntity. Dieser Sprung nutzte noch `fallbackToDestructiveMigration()` (die App
* befand sich vor dem produktiven Einsatz, keine echten Patientendaten zu migrieren).
* *
* `fallbackToDestructiveMigration()`: Da die App sich noch vor dem produktiven Einsatz befindet * Version 3 (vorher 2, MEDIA-004/PDF-003): `signatures.orderIndex` neu (stabile Reihenfolge
* und keine echten Patientendaten migriert werden müssen, wird hier bewusst auf eine * mehrerer Signaturen je Dokumentation) sowie `signatures.signerName` von NULLABLE auf NOT NULL.
* geschriebene `Migration`-Klasse verzichtet - die lokale Datenbank wird bei einem * Anders als beim Sprung 1->2 wird HIER eine echte, verlustfreie [Migration] verwendet
* Versionssprung einfach neu angelegt (bestehende lokale Testdaten auf dem Gerät/Emulator gehen * ([MIGRATION_2_3]) - reale Installationen können ab jetzt bereits Patienten-/
* dabei verloren). Das ist eine bewusste, vorübergehende Entwicklungsentscheidung: VOR dem * Dokumentationsdaten enthalten, die nicht stillschweigend durch einen destruktiven Fallback
* ersten produktiven Rollout muss dies durch eine echte `Migration(1, 2) { ... }` ersetzt * verloren gehen dürfen (Aufgabenstellung Abschnitt 23). Der bisherige
* werden, die reale Patientendaten erhält (insbesondere: die bisherige `address`-Spalte müsste * `fallbackToDestructiveMigration()`-Aufruf wurde deshalb entfernt; fehlt für einen künftigen
* dann sinnvoll in street/postalCode/city überführt werden, z. B. gesamten Altwert nach * Versionssprung eine passende Migration, bricht Room den Aufbau der Datenbank kontrolliert mit
* `street` und postalCode/city leer lassen, da eine automatische Text-Zerlegung einer * einer Exception ab, statt Daten unbemerkt zu löschen.
* Freitext-Adresse nicht zuverlässig möglich ist). *
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
* additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen.
*
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -47,8 +57,9 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
CheckItemStateEntity::class, CheckItemStateEntity::class,
PhotoEntity::class, PhotoEntity::class,
SignatureEntity::class, SignatureEntity::class,
WoundCarerEntity::class,
], ],
version = 2, version = 5,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DocumentationStatusConverter::class) @TypeConverters(DocumentationStatusConverter::class)
@@ -60,6 +71,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun checkItemStateDao(): CheckItemStateDao abstract fun checkItemStateDao(): CheckItemStateDao
abstract fun photoDao(): PhotoDao abstract fun photoDao(): PhotoDao
abstract fun signatureDao(): SignatureDao abstract fun signatureDao(): SignatureDao
abstract fun woundCarerDao(): WoundCarerDao
companion object { companion object {
private const val DATABASE_NAME = "wunddoku.db" private const val DATABASE_NAME = "wunddoku.db"
@@ -74,7 +86,8 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
DATABASE_NAME, DATABASE_NAME,
) )
.fallbackToDestructiveMigration() // s. Klassenkommentar: vorübergehend, vor Produktivbetrieb ersetzen // Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -0,0 +1,133 @@
package de.medapp.wunddoku.data.database
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Migration von Schema-Version 2 auf 3 (MEDIA-004/PDF-003: Mehrere Signaturen pro
* Dokumentation).
*
* Zwei Änderungen am `signatures`-Schema, die NICHT über ein einfaches `ALTER TABLE ... ADD
* COLUMN` abgebildet werden können:
*
* 1. Neue Spalte `orderIndex INTEGER NOT NULL` für eine stabile Reihenfolge mehrerer
* Signaturen (bisher gab es nur eine implizite, undefinierte Reihenfolge).
* 2. `signerName` wechselt von NULLABLE auf NOT NULL. SQLite kann eine bestehende Spalte nicht
* nachträglich auf NOT NULL umstellen - deshalb wird die Tabelle komplett neu aufgebaut
* (neue Tabelle mit Zielschema anlegen, Daten verlustfrei übernehmen, alte Tabelle
* entfernen, neue Tabelle umbenennen, Index wiederherstellen).
*
* Es wird NICHT angenommen, dass die Tabelle leer ist, nur weil die Signaturfunktion vor
* dieser Version über keine UI erreichbar war (Aufgabenstellung Abschnitt 22) - die Migration
* behandelt den allgemeinen Fall mit ggf. bereits vorhandenen Zeilen:
*
* - `orderIndex` wird pro `documentationId` anhand von `createdAt` (mit `id` als
* Tie-Breaker für den unwahrscheinlichen Fall exakt gleicher Zeitstempel) rückwirkend
* vergeben, beginnend bei 0 - über eine korrelierte Unterabfrage, da SQLite in der von Room
* unterstützten Form kein `ROW_NUMBER()`-Fenster in einem `UPDATE` bereitstellt.
* - `signerName` wird für Altzeilen mit NULL **oder** leerem/nur-Leerzeichen-Wert auf den klar
* als historisch fehlend erkennbaren technischen Platzhalter `"(nicht erfasst)"` gesetzt
* (ausdrücklich freigegeben - NICHT `""`, NICHT `"Unbekannt"`, da Letzteres wie ein echter
* Name wirken könnte). Der leere-String-Fall wurde defensiv ergänzt: `signerName` war in
* Schema 2 zwar nullable, aber nie auf "nicht blank" geprüft - ein rein theoretisch über
* einen früheren Code-Pfad denkbarer Leerstring soll denselben Platzhalter erhalten wie
* NULL, damit hier keine Zeile mit einem leeren, aber technisch "vorhandenen" Namen entsteht.
* Diese Regel gilt ausschließlich für migrierte Altbestände - neu erfasste Signaturen
* erzwingen weiterhin serverseitig (Repository) ein nicht-leeres, getrimmtes Pflichtfeld.
*
* Da die Signaturfunktion vor dieser Version über keine UI erreichbar war, ist in der Praxis auf
* so gut wie jeder realen Installation ohnehin mit einer leeren `signatures`-Tabelle zu rechnen
* - die obige Logik greift dann einfach nicht (INSERT ... SELECT liefert 0 Zeilen).
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `signatures_new` (
`id` TEXT NOT NULL,
`documentationId` TEXT NOT NULL,
`signerName` TEXT NOT NULL,
`fileName` TEXT NOT NULL,
`orderIndex` INTEGER NOT NULL,
`createdAt` INTEGER NOT NULL,
PRIMARY KEY(`id`),
FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO `signatures_new` (`id`, `documentationId`, `signerName`, `fileName`, `orderIndex`, `createdAt`)
SELECT
s.id,
s.documentationId,
CASE
WHEN s.signerName IS NULL OR TRIM(s.signerName) = '' THEN '(nicht erfasst)'
ELSE s.signerName
END,
s.fileName,
(
SELECT COUNT(*)
FROM signatures s2
WHERE s2.documentationId = s.documentationId
AND (
s2.createdAt < s.createdAt
OR (s2.createdAt = s.createdAt AND s2.id < s.id)
)
),
s.createdAt
FROM signatures s
""".trimIndent()
)
db.execSQL("DROP TABLE `signatures`")
db.execSQL("ALTER TABLE `signatures_new` RENAME TO `signatures`")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `signatures` (`documentationId`)")
}
}
/**
* Migration von Schema-Version 3 auf 4 (Änderungswunsch: Wundversorger-Register für
* Namensvorschläge und Standard-Vorbelegung im Signatur-Dialog).
*
* Rein additiv: eine neue, komplett eigenständige Tabelle `wound_carers` ohne Fremdschlüssel-
* Bezug zu einer bestehenden Tabelle (unabhängig vom Lebenszyklus einzelner Dokumentationen/
* Signaturen, s. [de.medapp.wunddoku.data.repository.WoundCarerRepository]). Keine bestehenden
* Tabellen/Daten werden hierbei verändert.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `wound_carers` (
`id` TEXT NOT NULL,
`name` TEXT NOT NULL,
`signatureFileName` TEXT NOT NULL,
`updatedAt` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
}
}
/**
* Migration von Schema-Version 4 auf 5 (finale fachliche Katalog-Inhalte: neuer Content-Typ
* "statischer Hinweistext", z. B. "+ bei Bedarf" bei "Empfohlene Wundversorgung").
*
* Rein additiv - eine einzelne neue, NOT NULL-Spalte mit Default `false` (0) auf einer
* bestehenden Tabelle. Anders als bei der Signaturen-Migration (2->3, dort war ein
* NULL->NOT-NULL-Wechsel auf einer bereits befüllten Spalte nötig) ist hier ein einfaches
* `ALTER TABLE ... ADD COLUMN` mit Default-Wert ausreichend und von SQLite direkt unterstützt -
* kein Tabellenneubau nötig. Bestehende Zeilen erhalten automatisch `isStaticHint = 0`
* (entspricht ihrem bisherigen, unveränderten Verhalten als normale Checkbox- bzw.
* Freitext-Zeile).
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `check_item_states` ADD COLUMN `isStaticHint` INTEGER NOT NULL DEFAULT 0"
)
}
}
@@ -15,6 +15,18 @@ import androidx.room.PrimaryKey
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des * zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am * Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
* Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer. * Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer.
* Derselbe Mechanismus wird jetzt auch für vom KATALOG selbst definierte Freitext-Checkboxen
* verwendet (z. B. "Wundgrund"/"Erscheinung" oder die vier Empfänger-Freitexte bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
*
* [isStaticHint]: NEU. Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B.
* "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
* fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel
* im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
* "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt.
*/ */
@Entity( @Entity(
tableName = "check_item_states", tableName = "check_item_states",
@@ -40,4 +52,5 @@ data class CheckItemStateEntity(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
) )
@@ -7,9 +7,14 @@ import androidx.room.PrimaryKey
/** /**
* Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an * Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an
* DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen * DocumentationEntity, damit eine Dokumentation beliebig viele Unterschriften unterschiedlicher
* (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter * Wundversorger besitzen kann (1:n, MEDIA-004). [fileName] verweist auf eine PNG-Datei unter
* context.filesDir/signatures/ – kein BLOB in SQLite. * context.filesDir/signatures/<documentationId>/ – kein BLOB in SQLite.
*
* Schema-Historie: In Version 2 war [signerName] noch nullable und es gab kein [orderIndex] -
* Migration 2->3 ([de.medapp.wunddoku.data.database.MIGRATION_2_3]) baut die Tabelle deshalb neu
* auf (SQLite kann eine Spalte nicht per ALTER TABLE von NULL auf NOT NULL umstellen) und füllt
* dabei sowohl [orderIndex] als auch fehlende [signerName]-Werte auf.
*/ */
@Entity( @Entity(
tableName = "signatures", tableName = "signatures",
@@ -28,7 +33,10 @@ import androidx.room.PrimaryKey
data class SignatureEntity( data class SignatureEntity(
@PrimaryKey val id: String, @PrimaryKey val id: String,
val documentationId: String, val documentationId: String,
val signerName: String?, /** Fachliches Pflichtfeld seit Schema 3 (non-null, getrimmt, nicht blank) - s. Repository. */
val signerName: String,
val fileName: String, val fileName: String,
/** Stabile Reihenfolge mehrerer Signaturen einer Dokumentation, analog [de.medapp.wunddoku.data.entity.PhotoEntity.orderIndex]. */
val orderIndex: Int,
val createdAt: Long, val createdAt: Long,
) )
@@ -0,0 +1,37 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger-Namen samt ihrer jeweils
* AKTUELLSTEN Signatur - AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: es gibt keine
* Anmeldung, keine Rechte, keine Verknüpfung zu historischen Dokumentationen, und (vorerst) keine
* eigene Verwaltungsansicht zum Bearbeiten/Löschen einzelner Einträge - ausschließlich internes
* Cache für zwei Komfortfunktionen im Signatur-Dialog:
*
* 1. Namensvorschläge nach den ersten eingegebenen Buchstaben.
* 2. Vorbelegung von Name UND Signatur mit dem zuletzt verwendeten Wundversorger beim Öffnen des
* Dialogs (weiterhin änderbar).
*
* WICHTIG: Diese Tabelle hat KEINEN Einfluss auf bereits gespeicherte [SignatureEntity]-Zeilen -
* jede historische Signatur bleibt ein unveränderlicher Snapshot (Name + Bild) zum Zeitpunkt
* ihrer Erstellung. [signatureFileName] hier wird bei jeder neu gezeichneten Signatur unter
* demselben Namen überschrieben ("die letzte geänderte Version wird gespeichert") - komplett
* unabhängig vom Speicherort/Lebenszyklus der pro Dokumentation gespeicherten Signaturen.
*/
@Entity(tableName = "wound_carers")
data class WoundCarerEntity(
@PrimaryKey val id: String,
/** Trimmter Anzeigename, wie zuletzt eingegeben. Abgleich beim Wiedererkennen erfolgt
* case-insensitive (s. WoundCarerDao), gespeichert wird die zuletzt getippte Schreibweise. */
val name: String,
/** Relative Referenz auf die aktuellste PNG-Signatur dieses Wundversorgers, verwaltet über
* eine eigene, von [de.medapp.wunddoku.signature.SignatureStorage]-Instanz mit eigenem
* Wurzelverzeichnis (unabhängig von jeder documentationId). */
val signatureFileName: String,
/** Zeitpunkt der letzten Verwendung/Aktualisierung - bestimmt sowohl die Sortierung der
* Namensvorschläge als auch, wer als Standard beim Öffnen des Dialogs vorbelegt wird
* (jeweils der zuletzt aktualisierte Eintrag). */
val updatedAt: Long,
)
@@ -40,12 +40,15 @@ fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
createdAt = Instant.ofEpochMilli(it.createdAt), createdAt = Instant.ofEpochMilli(it.createdAt),
) )
}, },
signatures = signatures.map { signatures = signatures
.sortedBy { it.orderIndex }
.map {
de.medapp.wunddoku.domain.model.Signature( de.medapp.wunddoku.domain.model.Signature(
id = it.id, id = it.id,
documentationId = it.documentationId, documentationId = it.documentationId,
signerName = it.signerName, signerName = it.signerName,
fileName = it.fileName, fileName = it.fileName,
orderIndex = it.orderIndex,
createdAt = Instant.ofEpochMilli(it.createdAt), createdAt = Instant.ofEpochMilli(it.createdAt),
) )
}, },
@@ -68,6 +71,7 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
order = itemOrder, order = itemOrder,
checked = checked, checked = checked,
isFreeText = isFreeText, isFreeText = isFreeText,
isStaticHint = isStaticHint,
freeTextValue = freeTextValue, freeTextValue = freeTextValue,
) )
@@ -23,8 +23,14 @@ 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.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.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureFileStorage import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation
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.time.Instant import java.time.Instant
@@ -38,6 +44,10 @@ import java.util.UUID
* *
* [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein * [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein
* frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation). * frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation).
* [pdfExporter] wird für [exportPdf] benötigt - der Exporter selbst kennt weder Room noch die
* documentationId, nur das fertig gemappte [de.medapp.wunddoku.pdf.PdfExportModel]; das Laden
* der Dokumentation und die Fehlerübersetzung ("Dokumentation existiert nicht") übernimmt
* bewusst das Repository, nicht der Exporter.
*/ */
class DocumentationRepository( class DocumentationRepository(
private val documentationDao: DocumentationDao, private val documentationDao: DocumentationDao,
@@ -47,7 +57,9 @@ class DocumentationRepository(
private val signatureDao: SignatureDao, private val signatureDao: SignatureDao,
private val patientDao: PatientDao, private val patientDao: PatientDao,
private val photoStorage: PhotoStorage, private val photoStorage: PhotoStorage,
private val signatureFileStorage: SignatureFileStorage, private val signatureStorage: SignatureStorage,
private val woundCarerRepository: WoundCarerRepository,
private val pdfExporter: DocumentationPdfExporter,
private val db: AppDatabase, private val db: AppDatabase,
) { ) {
@@ -112,12 +124,14 @@ class DocumentationRepository(
* - status: IN_PROGRESS * - status: IN_PROGRESS
* - continuedFromDocumentationId: ID der Quelle * - continuedFromDocumentationId: ID der Quelle
* - Unterschriften: werden NICHT kopiert * - Unterschriften: werden NICHT kopiert
* - Fotos: über [copyPhotos] steuerbar, Standard `false` - fachlich noch nicht final * - Fotos: werden standardmäßig mit übernommen (physisch eigenständige Kopien pro
* entschieden * Dokumentation, s. [PhotoStorage.duplicate]) - fachliche Entscheidung, ursprünglich als
* offen markiert, inzwischen bewusst so festgelegt. Über [copyPhotos] weiterhin
* abschaltbar, falls dafür später doch ein Anwendungsfall entsteht.
*/ */
suspend fun continueDocumentation( suspend fun continueDocumentation(
sourceDocumentationId: String, sourceDocumentationId: String,
copyPhotos: Boolean = false, copyPhotos: Boolean = true,
): Documentation = db.withTransaction { ): Documentation = db.withTransaction {
val source = documentationDao.getFullDocumentation(sourceDocumentationId) val source = documentationDao.getFullDocumentation(sourceDocumentationId)
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden") ?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
@@ -171,6 +185,7 @@ class DocumentationRepository(
itemOrder = item.itemOrder, itemOrder = item.itemOrder,
checked = item.checked, checked = item.checked,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
isStaticHint = item.isStaticHint,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
) )
} }
@@ -181,7 +196,11 @@ class DocumentationRepository(
if (copyPhotos) { if (copyPhotos) {
for (sourcePhoto in source.photos) { for (sourcePhoto in source.photos) {
val copiedFileName = photoStorage.duplicate(sourcePhoto.fileName) val copiedFileName = photoStorage.duplicate(
sourceDocumentationId = source.documentation.id,
sourceFileName = sourcePhoto.fileName,
targetDocumentationId = newDocumentationId,
)
photoDao.insert( photoDao.insert(
PhotoEntity( PhotoEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@@ -224,7 +243,7 @@ class DocumentationRepository(
} }
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
val fileName = photoStorage.copyFrom(sourceUri) val fileName = photoStorage.importFrom(documentationId, sourceUri)
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now() val now = Instant.now()
val entity = PhotoEntity( val entity = PhotoEntity(
@@ -247,44 +266,141 @@ class DocumentationRepository(
suspend fun removePhoto(photoId: String) { suspend fun removePhoto(photoId: String) {
val photo = photoDao.getById(photoId) ?: return val photo = photoDao.getById(photoId) ?: return
photoDao.delete(photo) photoDao.delete(photo)
runCatching { photoStorage.delete(photo.fileName) } runCatching { photoStorage.delete(photo.documentationId, photo.fileName) }
} }
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { /**
val fileName = signatureFileStorage.save(bitmap) * Erzeugt aus einer Wunddokumentation ein PDF im internen App-Speicher (PDF-001/002). Lädt
* die vollständige Dokumentation, übersetzt "nicht gefunden" in einen fachlichen Fehler
* statt einer NullPointerException, mappt anschließend auf das schlanke [de.medapp.wunddoku.pdf.PdfExportModel]
* (dabei werden über [DocumentationToPdfExportModelMapper] ausschließlich bearbeitete
* Blöcke gemäß der bestehenden BlockEditedPolicy sowie die historischen Snapshot-Werte
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
*/
suspend fun exportPdf(documentationId: String): PdfExportResult {
val documentation = getFullDocumentation(documentationId)
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
val model = DocumentationToPdfExportModelMapper.map(documentation)
return pdfExporter.export(model)
}
/**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
* Compose ausschließlich über das ViewModel aufgerufen, nie direkt - siehe Architektur:
* Compose führt keine Dateizugriffe selbst durch.
*/
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
/**
* Lädt ein Foto in Originalgröße für die Vollbildansicht beim Antippen einer Foto-Kachel
* (Änderungswunsch). Verwendet bewusst [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]
* statt eines unskalierten Rohzugriffs: Fotos werden bereits beim Import auf eine sinnvolle
* Maximalauflösung begrenzt (s. PhotoStorage-Importlogik), ein zusätzliches Herunterrechnen
* auf höchstens die tatsächliche Displaygröße ([maxWidthPx]/[maxHeightPx], vom Aufrufer aus
* der Bildschirmauflösung ermittelt) vermeidet unnötig hohen Speicherbedarf, ohne das Bild
* sichtbar zu verkleinern - der Nutzer sieht es exakt so groß wie der Bildschirm es ohnehin
* darstellen könnte.
*/
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? =
photoStorage.decodeFitted(documentationId, fileName, maxWidthPx, maxHeightPx)
/**
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
* genug für die direkte Anzeige. Liefert `null` bei fehlender/beschädigter Datei (kein
* Absturz, konsistent zu [loadPhotoThumbnail]).
*/
suspend fun loadSignatureBitmap(documentationId: String, fileName: String): Bitmap? =
signatureStorage.decode(documentationId, fileName)
/**
* Speichert eine neue Signatur (MEDIA-003/004). [signerName] ist fachliches Pflichtfeld:
* wird getrimmt, ein danach leerer String wird abgelehnt (Aufgabenstellung Abschnitt 8) -
* ebenso eine leere Zeichnung ([SignatureBitmapValidation.isBlank], Abschnitt 12: zweite,
* von der UI unabhängige Absicherungsebene). [orderIndex] wird analog [addPhoto] fortlaufend
* je Dokumentation vergeben, damit mehrere Signaturen eine stabile Reihenfolge haben
* (Anzeige UND spätere PDF-Reihenfolge, PDF-003). Die Namensvalidierung erfolgt VOR dem
* Schreiben der PNG-Datei, damit bei einem ungültigen Namen keine verwaiste Datei entsteht.
*
* Aktualisiert nach erfolgreichem Speichern zusätzlich das Wundversorger-Register
* ([WoundCarerRepository.recordSignatureUsage]) mit derselben Signatur - unabhängig davon,
* ob [bitmap] frisch gezeichnet oder aus einem Namensvorschlag im Dialog übernommen wurde.
* Das hat KEINEN Einfluss auf den hier gerade erzeugten, unveränderlichen [Signature]-
* Snapshot dieser Dokumentation.
*/
suspend fun saveSignature(documentationId: String, signerName: String, bitmap: Bitmap): Signature {
val trimmedName = signerName.trim()
require(trimmedName.isNotEmpty()) { "Der Name des Wundversorgers darf nicht leer sein." }
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
val fileName = signatureStorage.save(documentationId, bitmap)
val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now() val now = Instant.now()
val entity = SignatureEntity( val entity = SignatureEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
documentationId = documentationId, documentationId = documentationId,
signerName = signerName, signerName = trimmedName,
fileName = fileName, fileName = fileName,
orderIndex = nextOrderIndex,
createdAt = now.toEpochMilli(), createdAt = now.toEpochMilli(),
) )
signatureDao.insert(entity) signatureDao.insert(entity)
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now) woundCarerRepository.recordSignatureUsage(trimmedName, bitmap)
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now)
} }
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */ /** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. Signaturen sind nach dem
* Speichern unveränderlich (Aufgabenstellung Abschnitt 17) - "korrigieren" bedeutet fachlich
* immer löschen + neu erfassen, nie ein nachträgliches Bearbeiten. */
suspend fun removeSignature(signatureId: String) { suspend fun removeSignature(signatureId: String) {
val signature = signatureDao.getById(signatureId) ?: return val signature = signatureDao.getById(signatureId) ?: return
signatureDao.delete(signature) signatureDao.delete(signature)
runCatching { signatureFileStorage.delete(signature.fileName) } runCatching { signatureStorage.delete(signature.documentationId, signature.fileName) }
} }
suspend fun delete(documentationId: String) { suspend fun delete(documentationId: String) {
val entity = documentationDao.getById(documentationId) ?: return val entity = documentationDao.getById(documentationId) ?: return
val photos = photoDao.getForDocumentation(documentationId)
val signatures = signatureDao.getForDocumentation(documentationId)
documentationDao.delete(entity) documentationDao.delete(entity)
photos.forEach { runCatching { photoStorage.delete(it.fileName) } } // Je ein einziger Verzeichnis-Löschvorgang statt Einzeldateien für Fotos UND Signaturen
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } // - beide Storage-Implementierungen organisieren ihre Dateien pro documentationId in
// einem eigenen Unterordner (s. PhotoStorage-/SignatureStorage-Kommentar). Room-Cascade
// entfernt zwar die PhotoEntity-/SignatureEntity-Zeilen automatisch, aber keine Dateien
// aus filesDir - das übernimmt ausschließlich diese Methode. Da deleteAll() über den
// Ordnernamen (documentationId) statt über zuvor geladene DB-Zeilen arbeitet, muss hier
// - anders als vorher bei den Signaturen - auch keine Liste mehr vor dem DB-Delete
// geladen werden.
runCatching { photoStorage.deleteAll(documentationId) }
runCatching { signatureStorage.deleteAll(documentationId) }
} }
/** /**
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro * Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
* Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option * Katalog-Eintrag PLUS eine zusätzliche, automatisch angehängte generische Freitext-Option
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL * (isFreeText = true, Label "Freitext") - siehe DocumentationCatalog.FREE_TEXT_LABEL und
* und BlockDefinition.freeTextDefinitionId. * BlockDefinition.freeTextDefinitionId. Diese generische Option wird WEITERHIN für JEDEN
* Block angehängt, unabhängig davon, ob der Block selbst bereits eigene, fachlich benannte
* `isFreeText`-Einträge definiert (z. B. "Wundgrund + Erscheinung" oder "Kopie
* nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine
* zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet.
*
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText` und
* `isStaticHint` steuern direkt das Verhalten der jeweiligen Zeile (normale Checkbox,
* eigene benannte Freitext-Checkbox, oder nicht editierbarer Hinweistext) - keine
* block-spezifische Sonderlogik nötig, die Katalogdefinition allein entscheidet.
*
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
* "nur angehakte Elemente"-Regel im PDF-Export automatisch auch für sie, ohne dass diese
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
*
* Die generische Freitext-Option erscheint als ERSTES Element im Block (itemOrder = 0,
* niedriger als jeder Katalog-Eintrag, dessen `order` bei 1 beginnt). Betrifft ausschließlich
* NEU erstellte Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen
* behalten ihre historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s.
* Klassendokumentation zu titleSnapshot/labelSnapshot).
*/ */
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) { for (blockDef in DocumentationCatalog.blocks) {
@@ -302,29 +418,34 @@ class DocumentationRepository(
) )
) )
val normalItems = blockDef.items.map { itemDef -> val catalogItems = blockDef.items.map { itemDef ->
CheckItemStateEntity( CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = itemDef.id, checkItemDefinitionId = itemDef.id,
labelSnapshot = itemDef.label, labelSnapshot = itemDef.label,
itemOrder = itemDef.order, itemOrder = itemDef.order,
checked = false, // Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
isFreeText = false, // anderen Einträge (Checkbox oder eigene Freitext-Checkbox) starten
// unangehakt/leer, wie bisher.
checked = itemDef.isStaticHint,
isFreeText = itemDef.isFreeText,
isStaticHint = itemDef.isStaticHint,
freeTextValue = "", freeTextValue = "",
) )
} }
val freeTextItem = CheckItemStateEntity( val genericFreeTextItem = CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId, checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = blockDef.items.size + 1, itemOrder = 0,
checked = false, checked = false,
isFreeText = true, isFreeText = true,
isStaticHint = false,
freeTextValue = "", freeTextValue = "",
) )
checkItemStateDao.insertAll(normalItems + freeTextItem) checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
} }
} }
} }
@@ -0,0 +1,82 @@
package de.medapp.wunddoku.data.repository
import android.graphics.Bitmap
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.signature.SignatureStorage
import java.time.Instant
import java.util.UUID
/**
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger (Namensvorschläge +
* Vorbelegung im Signatur-Dialog mit Name UND Signatur des zuletzt verwendeten Wundversorgers).
*
* AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: keine Anmeldung, keine Rechte, keine
* Verknüpfung zu [DocumentationRepository]-Signaturen, und (vorerst) keine eigene
* Verwaltungsansicht. Ändert insbesondere NIE bereits gespeicherte, historische
* [de.medapp.wunddoku.domain.model.Signature]-Snapshots einzelner Dokumentationen - diese bleiben
* unveränderlich, wie ursprünglich festgelegt. Diese Klasse verwaltet eine komplett separate
* Tabelle (`wound_carers`) und ein separates Dateiverzeichnis
* (`filesDir/wound_carer_signatures/`, s. [de.medapp.wunddoku.signature.InternalSignatureStorage]).
*/
class WoundCarerRepository(
private val woundCarerDao: WoundCarerDao,
private val woundCarerSignatureStorage: SignatureStorage,
) {
/** Für die Vorbelegung des Signatur-Dialogs beim Öffnen: der zuletzt verwendete
* Wundversorger (app-weit, nicht auf eine einzelne Dokumentation beschränkt) - `null`, wenn
* noch nie eine Signatur gespeichert wurde. */
suspend fun getMostRecentlyUsed(): WoundCarer? = woundCarerDao.getMostRecentlyUsed()?.toDomain()
/** Namensvorschläge nach den ersten eingegebenen Buchstaben. Leerer/blank [prefix] liefert
* bewusst keine Vorschläge (kein "alle anzeigen" bei leerem Feld). */
suspend fun search(prefix: String, limit: Int = 5): List<WoundCarer> {
val trimmed = prefix.trim()
if (trimmed.isEmpty()) return emptyList()
return woundCarerDao.searchByNamePrefix(trimmed, limit).map { it.toDomain() }
}
/** Lädt die aktuell hinterlegte Signatur eines bekannten Wundversorgers, z. B. zur Anzeige
* einer Vorschau im Signatur-Dialog nach Auswahl eines Namensvorschlags. */
suspend fun loadSignatureBitmap(woundCarerId: String, fileName: String): Bitmap? =
woundCarerSignatureStorage.decode(woundCarerId, fileName)
/**
* Wird bei JEDER erfolgreich gespeicherten Signatur aufgerufen (s.
* [DocumentationRepository.saveSignature]) und aktualisiert das Register: existiert unter
* [name] (getrimmt, case-insensitiver Abgleich) bereits ein Eintrag, wird dessen Signatur
* durch [bitmap] ersetzt (alte Datei wird gelöscht) und der Zeitstempel aktualisiert -
* "die letzte geänderte Version der Unterschrift eines Wundversorgers wird gespeichert".
* Andernfalls wird ein neuer Eintrag angelegt. Verwendet dieselbe [bitmap]-Instanz, die auch
* als historischer Snapshot der jeweiligen Dokumentation gespeichert wird - es wird lediglich
* eine ZWEITE, unabhängige PNG-Kopie in einem eigenen Verzeichnis abgelegt (kein Teilen
* derselben Datei), damit ein späteres Löschen der Dokumentation dieses Register niemals
* beeinträchtigen kann.
*/
suspend fun recordSignatureUsage(name: String, bitmap: Bitmap) {
val existing = woundCarerDao.findByName(name)
val id = existing?.id ?: UUID.randomUUID().toString()
val newFileName = woundCarerSignatureStorage.save(id, bitmap)
woundCarerDao.insertOrUpdate(
WoundCarerEntity(
id = id,
name = name,
signatureFileName = newFileName,
updatedAt = System.currentTimeMillis(),
)
)
val oldFileName = existing?.signatureFileName
if (oldFileName != null && oldFileName != newFileName) {
runCatching { woundCarerSignatureStorage.delete(id, oldFileName) }
}
}
private fun WoundCarerEntity.toDomain() = WoundCarer(
id = id,
name = name,
signatureFileName = signatureFileName,
updatedAt = Instant.ofEpochMilli(updatedAt),
)
}
@@ -4,10 +4,13 @@ import android.content.Context
import de.medapp.wunddoku.data.database.AppDatabase import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.InternalPhotoStorage import de.medapp.wunddoku.media.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.InternalSignatureFileStorage import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.signature.SignatureFileStorage import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage
/** /**
* Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories, * Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories,
@@ -26,12 +29,27 @@ class AppContainer(context: Context) {
private val database: AppDatabase by lazy { AppDatabase.getInstance(context) } private val database: AppDatabase by lazy { AppDatabase.getInstance(context) }
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) } private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) } private val signatureStorage: SignatureStorage by lazy { InternalSignatureStorage(context) }
/** Eigenes Wurzelverzeichnis, komplett unabhängig von den dokumentationsbezogenen
* Signaturen - s. [InternalSignatureStorage]-Klassenkommentar. */
private val woundCarerSignatureStorage: SignatureStorage by lazy {
InternalSignatureStorage(context, rootDirName = "wound_carer_signatures")
}
private val pdfExporter: DocumentationPdfExporter by lazy {
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
}
val patientRepository: PatientRepository by lazy { val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao()) PatientRepository(patientDao = database.patientDao())
} }
val woundCarerRepository: WoundCarerRepository by lazy {
WoundCarerRepository(
woundCarerDao = database.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
}
val documentationRepository: DocumentationRepository by lazy { val documentationRepository: DocumentationRepository by lazy {
DocumentationRepository( DocumentationRepository(
documentationDao = database.documentationDao(), documentationDao = database.documentationDao(),
@@ -41,7 +59,9 @@ class AppContainer(context: Context) {
signatureDao = database.signatureDao(), signatureDao = database.signatureDao(),
patientDao = database.patientDao(), patientDao = database.patientDao(),
photoStorage = photoStorage, photoStorage = photoStorage,
signatureFileStorage = signatureFileStorage, signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
db = database, db = database,
) )
} }
@@ -3,10 +3,30 @@ package de.medapp.wunddoku.domain.catalog
/** /**
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen. * Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
* *
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter * Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte"). * handschriftlich vorgegebene 22-Punkte-Reihenfolge, ergänzt/verfeinert nach Abgleich mit dem
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser * ursprünglichen Papierformular ("Wunddokumentation" von Harald Klock) - dabei wurden:
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden. *
* - mehrere Abschnitte, die im Chat/der handschriftlichen Liste nur als Freitext geführt waren,
* auf dem Papierbogen aber eine differenzierte Checkbox-Struktur zeigen, entsprechend
* erweitert (Wundgrund, Belag, Wundrand, Exsudat, Größe, Fistel/Tasche, Lokalisation,
* Empfohlene Wundversorgung),
* - zwei auf dem Papierbogen vorhandene, aber in der handschriftlichen Liste fehlende
* Abschnitte wieder aufgenommen ("Weitere Überlegung" - vermutlich versehentlich in der
* handschriftlichen Liste ausgelassen; "Nächster Termin" - auf dem Bogen als eigenes Feld
* vorhanden),
* - "Verlauf" bewusst NICHT um die alten Papier-Checkboxen erweitert (explizit vom Kunden
* bestätigt: bleibt reiner Freitext, die alte Checkbox-Auswahl wurde bewusst verworfen),
* - "Wundumgebung" und "Auswirkungen auf das alltägliche Leben" um die Papier-Checkbox-Listen
* ergänzt, jeweils MIT vorangestelltem freiem Textfeld (die ohnehin an jedem Block
* automatisch vorhandene generische Freitext-Option deckt das ab, s. [FREE_TEXT_LABEL]),
* - "Wundart" um die Papier-Checkbox "nicht bekannt/diagnostiziert" ergänzt (zusätzlich zum
* Freitext - NICHT Teil der früher verworfenen Wundarten-Liste, sondern ein eigenständiges
* Status-Flag).
*
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
* Inhalte (Wundart-Typenliste, Verlauf-Checkboxen, "Schmerzempfinden 1-10") sind weiterhin
* bewusst NICHT enthalten.
* *
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich * Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument * nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
@@ -16,51 +36,370 @@ package de.medapp.wunddoku.domain.catalog
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke * Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie * oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die * Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). * historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). Auf 3 erhöht mit dem
* Abgleich gegen das ursprüngliche Papierformular.
*/ */
object DocumentationCatalog { object DocumentationCatalog {
const val CATALOG_VERSION: Int = 1 const val CATALOG_VERSION: Int = 3
/** /**
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch: * Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral * "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral
* hier definiert, damit Repository (Erstellung/Fortsetzen) und ggf. der spätere PDF-Export * hier definiert, damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe
* dieselbe Bezeichnung verwenden. * Bezeichnung verwenden. Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die
* fachlich "nur Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
*/ */
const val FREE_TEXT_LABEL: String = "Freitext" const val FREE_TEXT_LABEL: String = "Freitext"
val blocks: List<BlockDefinition> = listOf( val blocks: List<BlockDefinition> = listOf(
BlockDefinition( BlockDefinition(
id = "block_01", id = "wundart",
title = "Block 1", title = "Wundart",
order = 1, order = 1,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1), CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "nicht bekannt/diagnostiziert", order = 1),
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_02", id = "lokalisation",
title = "Block 2", title = "Lokalisation",
order = 2, order = 2,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1), CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_03", id = "groesse",
title = "Block 3", title = "Größe",
order = 3, order = 3,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1), CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "multiple Wunden mit max. (cm)", order = 1, isFreeText = true),
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2), CheckItemDefinition(id = "groesse_wunde", label = "Wunde", order = 2),
CheckItemDefinition(id = "groesse_areal", label = "Areal", order = 3),
CheckItemDefinition(id = "groesse_masse", label = "Maße (Länge x Breite, ca. cm)", order = 4, isFreeText = true),
CheckItemDefinition(id = "groesse_vertikale_tiefe", label = "Vertikale Tiefe (ca. cm)", order = 5, isFreeText = true),
), ),
), ),
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen BlockDefinition(
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch. id = "verlauf",
title = "Verlauf",
order = 4,
items = emptyList(),
),
BlockDefinition(
id = "exsudat",
title = "Exsudat",
order = 5,
items = listOf(
CheckItemDefinition(id = "exsudat_kein", label = "Kein", order = 1),
CheckItemDefinition(id = "exsudat_wenig", label = "Wenig", order = 2),
CheckItemDefinition(id = "exsudat_maessig", label = "Mäßig", order = 3),
CheckItemDefinition(id = "exsudat_viel", label = "Viel", order = 4),
CheckItemDefinition(id = "exsudat_uebermaessig", label = "Übermäßig", order = 5),
CheckItemDefinition(id = "exsudat_waessrig", label = "Wässrig", order = 6),
CheckItemDefinition(id = "exsudat_zaehfluessig", label = "Zähflüssig", order = 7),
CheckItemDefinition(id = "exsudat_eitrig", label = "Eitrig", order = 8),
CheckItemDefinition(id = "exsudat_blutig", label = "Blutig", order = 9),
CheckItemDefinition(id = "exsudat_klar", label = "Klar", order = 10),
CheckItemDefinition(id = "exsudat_gelblich", label = "Gelblich", order = 11),
CheckItemDefinition(id = "exsudat_gruenlich", label = "Grünlich", order = 12),
CheckItemDefinition(id = "exsudat_braeunlich", label = "Bräunlich", order = 13),
CheckItemDefinition(id = "exsudat_roetlich", label = "Rötlich", order = 14),
CheckItemDefinition(id = "exsudat_weisslich", label = "Weißlich", order = 15),
),
),
BlockDefinition(
id = "wundgrund_erscheinung",
title = "Wundgrund + Erscheinung",
order = 6,
items = listOf(
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 1),
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 2),
CheckItemDefinition(id = "wundgrund_knospig", label = "Knospig", order = 3),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 4),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 5),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 6),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 7),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 8),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 9),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 10),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 11),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 12),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 13),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 14),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 15, isFreeText = true),
),
),
BlockDefinition(
id = "belag",
title = "Belag",
order = 7,
items = listOf(
CheckItemDefinition(id = "belag_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "belag_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "belag_fibrin", label = "Fibrin", order = 3),
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 7),
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 8),
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 9),
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 10),
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 11),
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 12),
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 13),
),
),
BlockDefinition(
id = "wundrand",
title = "Wundrand",
order = 8,
items = listOf(
CheckItemDefinition(id = "wundrand_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundrand_partiell_unterschiedlich", label = "Partiell unterschiedlich", order = 2),
CheckItemDefinition(id = "wundrand_rosarot", label = "Rosarot", order = 3),
CheckItemDefinition(id = "wundrand_rot", label = "Rot", order = 4),
CheckItemDefinition(id = "wundrand_blaeulich", label = "Bläulich", order = 5),
CheckItemDefinition(id = "wundrand_blass", label = "Blass", order = 6),
CheckItemDefinition(id = "wundrand_blutig", label = "Blutig", order = 7),
CheckItemDefinition(id = "wundrand_mit_kruste_belegt", label = "Mit Kruste belegt", order = 8),
CheckItemDefinition(id = "wundrand_mazeriert", label = "Mazeriert", order = 9),
CheckItemDefinition(id = "wundrand_unterminiert", label = "Unterminiert", order = 10),
CheckItemDefinition(id = "wundrand_trocken", label = "Trocken", order = 11),
CheckItemDefinition(id = "wundrand_glatt", label = "Glatt", order = 12),
CheckItemDefinition(id = "wundrand_flach_einlaufend", label = "Flach einlaufend", order = 13),
CheckItemDefinition(id = "wundrand_wallartig", label = "Wallartig", order = 14),
CheckItemDefinition(id = "wundrand_wulstig", label = "Wulstig", order = 15),
CheckItemDefinition(id = "wundrand_zerklueftet", label = "Zerklüftet", order = 16),
CheckItemDefinition(id = "wundrand_ueberhaengende_strukturen_lefzen", label = "Überhängende Strukturen/Lefzen", order = 17),
),
),
BlockDefinition(
id = "wundumgebung",
title = "Wundumgebung",
order = 9,
items = listOf(
CheckItemDefinition(id = "wundumgebung_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundumgebung_oedem", label = "Ödem", order = 2),
CheckItemDefinition(id = "wundumgebung_ekzem", label = "Ekzem", order = 3),
CheckItemDefinition(id = "wundumgebung_schuppig", label = "Schuppig", order = 4),
CheckItemDefinition(id = "wundumgebung_narbengewebe", label = "Narbengewebe", order = 5),
CheckItemDefinition(id = "wundumgebung_hornhaut_verhaertet", label = "Hornhaut/verhärtet", order = 6),
CheckItemDefinition(id = "wundumgebung_geroetet", label = "Gerötet", order = 7),
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 11),
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 12),
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 13),
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 14),
),
),
BlockDefinition(
id = "fistel_tasche",
title = "Fistel / Tasche",
order = 10,
items = listOf(
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
CheckItemDefinition(id = "fistel_tasche_von_bis_uhr", label = "Von ... bis ... Uhr", order = 4, isFreeText = true),
CheckItemDefinition(id = "fistel_tasche_tiefe", label = "Tiefe (ca. cm)", order = 5, isFreeText = true),
),
),
BlockDefinition(
id = "entzuendungszeichen",
title = "Entzündungszeichen",
order = 11,
items = listOf(
CheckItemDefinition(id = "entzuendungszeichen_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "entzuendungszeichen_temperatur_erhoeht", label = "Temperatur erhöht", order = 2),
CheckItemDefinition(id = "entzuendungszeichen_schwellung", label = "Schwellung", order = 3),
CheckItemDefinition(id = "entzuendungszeichen_roetung", label = "Rötung", order = 4),
CheckItemDefinition(id = "entzuendungszeichen_funktionsstoerung", label = "Funktionsstörung", order = 5),
CheckItemDefinition(id = "entzuendungszeichen_schmerz", label = "Schmerz", order = 6),
),
),
BlockDefinition(
id = "geruch",
title = "Geruch",
order = 12,
items = listOf(
CheckItemDefinition(id = "geruch_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "geruch_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "geruch_gering", label = "Gering", order = 3),
CheckItemDefinition(id = "geruch_deutlich_wahrnehmbar", label = "Deutlich wahrnehmbar", order = 4),
),
),
BlockDefinition(
id = "schmerz",
title = "Schmerz",
order = 13,
items = listOf(
CheckItemDefinition(id = "schmerz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "schmerz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "schmerz_immer", label = "Immer", order = 3),
CheckItemDefinition(id = "schmerz_zeitweise", label = "Zeitweise", order = 4),
CheckItemDefinition(id = "schmerz_bei_verbandwechsel", label = "Bei Verbandwechsel", order = 5),
CheckItemDefinition(id = "schmerz_vorwiegend_nachts", label = "Vorwiegend nachts", order = 6),
CheckItemDefinition(id = "schmerz_wunde", label = "Wunde", order = 7),
CheckItemDefinition(id = "schmerz_umgebung", label = "Umgebung", order = 8),
),
),
BlockDefinition(
id = "juckreiz",
title = "Juckreiz",
order = 14,
items = listOf(
CheckItemDefinition(id = "juckreiz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "juckreiz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "juckreiz_wunde", label = "Wunde", order = 3),
CheckItemDefinition(id = "juckreiz_umgebung", label = "Umgebung", order = 4),
CheckItemDefinition(id = "juckreiz_immer_oft", label = "Immer / oft", order = 5),
CheckItemDefinition(id = "juckreiz_zeitweise", label = "Zeitweise", order = 6),
),
),
BlockDefinition(
id = "vitalwerte",
title = "Vitalwerte",
order = 15,
items = emptyList(),
),
BlockDefinition(
id = "auswirkungen_alltag",
title = "Auswirkungen auf das alltägliche Leben",
order = 16,
items = listOf(
CheckItemDefinition(id = "auswirkungen_alltag_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "auswirkungen_alltag_nicht_ermittelbar", label = "Nicht ermittelbar", order = 3),
),
),
BlockDefinition(
id = "bekannte_allergie",
title = "Bekannte Allergie",
order = 17,
items = emptyList(),
),
BlockDefinition(
id = "beratung_erfolgte",
title = "Beratung erfolgte",
order = 18,
items = listOf(
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
CheckItemDefinition(id = "beratung_ernaehrung", label = "Ernährung", order = 3),
CheckItemDefinition(id = "beratung_lagerung_druckentlastung", label = "Lagerung / Druckentlastung", order = 4),
CheckItemDefinition(id = "beratung_kleidung_schuhe", label = "Kleidung / Schuhe", order = 5),
CheckItemDefinition(id = "beratung_hautpflege", label = "Hautpflege", order = 6),
),
),
BlockDefinition(
id = "weitere_ueberlegung",
title = "Weitere Überlegung",
order = 19,
items = listOf(
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
CheckItemDefinition(id = "weitere_ueberlegung_wundabstrich", label = "Wundabstrich", order = 3),
CheckItemDefinition(id = "weitere_ueberlegung_angepasste_schmerztherapie", label = "Angepasste Schmerztherapie", order = 4),
CheckItemDefinition(id = "weitere_ueberlegung_anpassung_wundversorgung", label = "Anpassung der Wundversorgung", order = 5),
CheckItemDefinition(id = "weitere_ueberlegung_facharztliches_konsil", label = "Fachärztliches Konsil", order = 6),
CheckItemDefinition(id = "weitere_ueberlegung_ernaehrungsberatung", label = "Ernährungsberatung", order = 7),
CheckItemDefinition(id = "weitere_ueberlegung_pflegeberatung", label = "Pflegeberatung", order = 8),
CheckItemDefinition(id = "weitere_ueberlegung_hilfsmittel", label = "Hilfsmittel", order = 9),
CheckItemDefinition(id = "weitere_ueberlegung_bewegung_lagerungsplan", label = "Bewegung / Lagerungsplan", order = 10),
CheckItemDefinition(id = "weitere_ueberlegung_verbandwechsel_pflegedienst", label = "Verbandwechsel durch Pflegefachkräfte / Pflegedienst", order = 11),
CheckItemDefinition(
id = "weitere_ueberlegung_aerztliche_abklaerung_dringend",
label = "Ärztliche Abklärung zeitnah/dringend erforderlich / erbeten, Gefahr von bleibenden körperlichen Schäden / lebensbedrohlichen Prozessen",
order = 12,
),
),
),
BlockDefinition(
id = "sonstiges",
title = "Sonstiges",
order = 20,
items = emptyList(),
),
BlockDefinition(
id = "empfohlene_wundversorgung",
title = "Empfohlene Wundversorgung",
order = 21,
items = listOf(
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
CheckItemDefinition(id = "empfohlene_wundversorgung_getraenkte_kompresse", label = "Getränkte Kompresse für ca. ... Min. auflegen", order = 6, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9),
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_secura", label = "Secura", order = 15),
CheckItemDefinition(id = "empfohlene_wundversorgung_cavilon", label = "Cavilon", order = 16),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 17, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 18),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 20, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 21),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 22),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband", label = "Kompressionsverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 27, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 28, isStaticHint = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 29,
isStaticHint = true,
),
),
),
BlockDefinition(
id = "ziel",
title = "Ziel",
order = 22,
items = listOf(
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
CheckItemDefinition(id = "ziel_abpolsterung", label = "Abpolsterung", order = 3),
CheckItemDefinition(id = "ziel_schmerzreduktion", label = "Schmerzreduktion", order = 4),
CheckItemDefinition(id = "ziel_angepasstes_exsudatmanagement", label = "Angepasstes Exsudatmanagement", order = 5),
CheckItemDefinition(id = "ziel_stabile_wundverhaeltnisse", label = "Stabile Wundverhältnisse", order = 6),
CheckItemDefinition(id = "ziel_abheilung", label = "Abheilung", order = 7),
CheckItemDefinition(id = "ziel_geruchsreduzierung", label = "Geruchsreduzierung", order = 8),
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
),
),
BlockDefinition(
id = "kopie_nachrichtlich_an",
title = "Kopie nachrichtlich an",
order = 23,
items = listOf(
CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_pflege", label = "Pflege", order = 2, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_patient", label = "Patient", order = 3, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_sonstige", label = "Sonstige", order = 4, isFreeText = true),
),
),
BlockDefinition(
id = "naechster_termin",
title = "Nächster Termin",
order = 24,
items = emptyList(),
),
) )
fun findBlock(blockDefinitionId: String): BlockDefinition? = fun findBlock(blockDefinitionId: String): BlockDefinition? =
@@ -74,16 +413,39 @@ data class BlockDefinition(
val items: List<CheckItemDefinition>, val items: List<CheckItemDefinition>,
) { ) {
/** /**
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte Freitext-Option * Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte, generische
* dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein Bestandteil von [items], * Freitext-Option dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein
* da die Freitext-Option kein fachlicher Katalog-Eintrag ist, sondern strukturell zu jedem * Bestandteil von [items], da diese generische Freitext-Option kein fachlicher
* Block gehört. * Katalog-Eintrag ist, sondern strukturell zu JEDEM Block gehört - unabhängig davon, ob
* der Block selbst bereits eigene `isFreeText`-Einträge in [items] definiert.
*/ */
val freeTextDefinitionId: String get() = "${id}_freetext" val freeTextDefinitionId: String get() = "${id}_freetext"
} }
/**
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
*
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false).
*
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch an JEDEN Block angehängten
* generischen Freitext-Option (s. DocumentationRepository.insertBlocksFromCatalog), nur mit
* eigenem Label statt "Freitext". Ein Block kann beliebig viele solcher Einträge definieren.
*
* [isStaticHint] = true: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" oder eine
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
* Text selbst, keine Checkbox-Beschriftung.
*
* [isFreeText] und [isStaticHint] schließen sich fachlich gegenseitig aus (ein Eintrag ist
* entweder normale Checkbox, Freitext-Checkbox oder Hinweistext) - es gibt aber bewusst keine
* Laufzeitprüfung dafür, um die Katalogdefinition einfach zu halten; beide gleichzeitig auf
* `true` zu setzen ergibt schlicht keinen sinnvollen Katalogeintrag.
*/
data class CheckItemDefinition( data class CheckItemDefinition(
val id: String, val id: String,
val label: String, val label: String,
val order: Int, val order: Int,
val isFreeText: Boolean = false,
val isStaticHint: Boolean = false,
) )
@@ -9,12 +9,17 @@ package de.medapp.wunddoku.domain.model
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt * historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
* dargestellt werden. * dargestellt werden.
* *
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am * [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT als separates Feld am
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block * Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt. Jeder Block
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`, * bekommt automatisch genau ein CheckItem mit `isFreeText = true`; zusätzlich kann der Katalog
* ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren * selbst beliebig viele weitere `isFreeText = true`-Optionen mit eigenem, fachlichem Label
* PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu * definieren (z. B. "Wundgrund", "Erscheinung", oder die vier Empfänger bei "Kopie
* unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen. * nachrichtlich an") - technisch identisch, nur mit eigenem Label statt des generischen
* "Freitext". Ist [isFreeText] `false`, ist [freeTextValue] stets leer und ohne Bedeutung.
*
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
* Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity-
* Kommentar), zählt aber NICHT als Bearbeitung (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]).
*/ */
data class CheckItem( data class CheckItem(
val checkItemStateId: String, val checkItemStateId: String,
@@ -24,4 +29,5 @@ data class CheckItem(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
) )
@@ -5,15 +5,18 @@ import java.time.Instant
/** /**
* Eine digitale Unterschrift zu einer Wunddokumentation. * Eine digitale Unterschrift zu einer Wunddokumentation.
* *
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später * Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit beliebig
* mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können * viele Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
* (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen * (1:n-Beziehung, MEDIA-004). [fileName] verweist auf eine PNG-Datei im internen App-Speicher
* App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11. * (kein BLOB in SQLite). [signerName] ist ein historischer Snapshot: spätere Änderungen an
* eventuellen Wundversorger-Stammdaten dürfen bereits gespeicherte Signaturen nicht rückwirkend
* verändern. [orderIndex] bestimmt die stabile Anzeige-/Export-Reihenfolge mehrerer Signaturen.
*/ */
data class Signature( data class Signature(
val id: String, val id: String,
val documentationId: String, val documentationId: String,
val signerName: String?, val signerName: String,
val fileName: String, val fileName: String,
val orderIndex: Int,
val createdAt: Instant, val createdAt: Instant,
) )
@@ -0,0 +1,16 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
/**
* Ein zuletzt verwendeter Wundversorger samt seiner aktuellsten Signatur - reines Komfort-Cache
* für Namensvorschläge und Vorbelegung im Signatur-Dialog, KEIN Benutzer-/Account-Objekt (s.
* [de.medapp.wunddoku.data.entity.WoundCarerEntity]). Hat keinerlei Einfluss auf bereits
* gespeicherte, historische [Signature]-Snapshots einzelner Dokumentationen.
*/
data class WoundCarer(
val id: String,
val name: String,
val signatureFileName: String,
val updatedAt: Instant,
)
@@ -23,8 +23,15 @@ object BlockEditedPolicy {
* nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt * nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt
* ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon * ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon
* Text eingegeben wurde. * Text eingegeben wurde.
*
* EINZIGE Ausnahme: Hinweistext-Zeilen (CheckItem.isStaticHint = true) werden hierbei
* IGNORIERT, obwohl ihr `checked` technisch immer `true` ist (s. Entity-Kommentar zu
* isStaticHint) - ein reiner, nicht editierbarer Hinweistext ist keine Nutzereingabe und
* darf einen Block nicht allein dadurch als "bearbeitet" erscheinen lassen (z. B.
* "Empfohlene Wundversorgung" enthält zwei feste Hinweiszeilen, soll aber nur dann als
* bearbeitet gelten, wenn tatsächlich eine Empfehlung eingetragen wurde).
*/ */
fun isEdited(block: DocumentationBlock): Boolean { fun isEdited(block: DocumentationBlock): Boolean {
return block.items.any { it.checked } return block.items.any { it.checked && !it.isStaticHint }
} }
} }
@@ -0,0 +1,95 @@
package de.medapp.wunddoku.media
import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.exifinterface.media.ExifInterface
/**
* Reine, von Android-Kontext (Storage/ContentResolver) unabhängige Bildverarbeitungs-
* Hilfsfunktionen - bewusst getrennt von [PhotoStorage], das für die Dateisystem-/
* ContentResolver-Koordination zuständig ist. Dadurch einzeln testbar (Unit-Test ohne
* Instrumentierung möglich, da nur `android.graphics`-Typen ohne Kontext verwendet werden).
*/
object ImageDownsampler {
/**
* Maximale längste Kante gespeicherter Fotos in Pixeln.
*
* Begründung: 2000px ist deutlich mehr, als für die Bildschirmanzeige auf Smartphone/Tablet
* nötig ist, aber klein genug, um RAM-/Speicherverbrauch bei modernen 12-48-Megapixel-
* Smartphone-Fotos drastisch zu reduzieren (ein 48-MP-Foto hätte z. B. ~8000x6000px - mehr
* als 60x so viele Pixel wie 2000px auf der langen Kante). 2000px reicht außerdem für eine
* spätere PDF-Einbettung in guter Druck-/Bildschirmqualität, ohne Rohaufnahmen dauerhaft in
* voller Auflösung vorzuhalten.
*/
const val MAX_STORED_DIMENSION_PX = 2000
/**
* Zielgröße für Vorschaubilder in der Fotoübersicht der Dokumentationsbearbeitung. Deutlich
* kleiner als die gespeicherte Datei, damit beim Rendern eines Fotogitters nicht
* versehentlich für jede Kachel die volle 2000px-Version decodiert wird.
*/
const val THUMBNAIL_MAX_DIMENSION_PX = 300
/**
* JPEG-Qualität beim Speichern (0-100).
*
* Begründung: 90 ist bewusst konservativ hoch gewählt. Die Bilder werden später Bestandteil
* medizinischer Dokumentations-PDFs (Wundränder, Verfärbungen, Größenverhältnisse müssen
* erkennbar bleiben) - eine aggressivere Kompression (z. B. 70-80) würde solche feinen
* Details sichtbar beeinträchtigen. 90 reduziert die Dateigröße gegenüber verlustfreier
* Speicherung trotzdem spürbar.
*/
const val JPEG_QUALITY = 90
/**
* Berechnet den `inSampleSize`-Wert für `BitmapFactory.Options`, mit dem ein Bild beim
* Decodieren bereits speicherschonend heruntergerechnet gelesen wird (Zweierpotenz-
* Downsampling direkt im Decoder), statt es vollständig in Originalauflösung zu laden und
* erst danach zu skalieren - das ist der von Android empfohlene Weg, um
* OutOfMemoryError bei großen Fotos zu vermeiden.
*/
fun computeInSampleSize(sourceWidth: Int, sourceHeight: Int, targetMaxDimension: Int): Int {
if (sourceWidth <= 0 || sourceHeight <= 0 || targetMaxDimension <= 0) return 1
var inSampleSize = 1
val largestSourceDimension = maxOf(sourceWidth, sourceHeight)
if (largestSourceDimension > targetMaxDimension) {
val halfDimension = largestSourceDimension / 2
while (halfDimension / inSampleSize >= targetMaxDimension) {
inSampleSize *= 2
}
}
return inSampleSize
}
/**
* Wendet die EXIF-Ausrichtung [exifOrientation] auf [bitmap] an, sodass das Ergebnis bereits
* in der visuell korrekten Ausrichtung vorliegt (viele Smartphone-Kameras schreiben Fotos
* sensor-nativ und speichern die tatsächliche Ausrichtung nur als EXIF-Metadatum).
*
* Liefert bei `ORIENTATION_NORMAL`/`ORIENTATION_UNDEFINED` (inkl. fehlender EXIF-Daten,
* s. [PhotoStorage]) dieselbe Bitmap-Instanz unverändert zurück - kein unnötiges Neu-
* Rendern. Die aufrufende Stelle ist dafür verantwortlich, das Original zu recyceln, falls
* eine neue, rotierte Instanz zurückgegeben wurde und das Original nicht mehr gebraucht wird.
*/
fun applyExifOrientation(bitmap: Bitmap, exifOrientation: Int): Bitmap {
val matrix = Matrix()
when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f)
matrix.postScale(-1f, 1f)
}
else -> return bitmap // NORMAL oder UNDEFINED - keine Korrektur nötig
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
}
@@ -1,65 +1,292 @@
package de.medapp.wunddoku.media package de.medapp.wunddoku.media
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.math.roundToInt
/** /**
* Verwaltet die physischen Bilddateien im internen App-Speicher (context.filesDir/photos/). * Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation:
* *
* WICHTIG (Phase-Hinweis): Diese Implementierung kopiert Bilder in Phase 2 zunächst * ```
* unverändert (1:1-Kopie der ausgewählten Datei). Downsampling großer Smartphone-Fotos und * filesDir/
* EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der * └── photos/
* Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten, * └── <documentationId>/
* dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern. * ├── <uuid>.jpg
* └── ...
* ```
* *
* Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung * Ein eigenes Unterverzeichnis pro Dokumentation (statt eines einzigen flachen Ordners für alle
* einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21). * Fotos) hat zwei konkrete Vorteile: (1) Löschen einer kompletten Dokumentation kann das
* gesamte Verzeichnis in einem Schritt entfernen ([deleteAll]), statt Einzeldateien
* durchzugehen; (2) beim Fortsetzen einer Dokumentation mit kopierten Fotos landen die Kopien
* physisch getrennt vom Original in einem eigenen Verzeichnis - Löschen eines Fotos in der
* einen Dokumentation kann das Foto der anderen dadurch strukturell nicht berühren.
*
* Dateinamen sind durchgehend zufällige UUIDs, unabhängig vom ursprünglichen Dateinamen des
* importierten Fotos - vermeidet Kollisionen und verhindert, dass ein möglicherweise
* sensibler/persönlicher Originaldateiname ins interne Dateisystem übernommen wird.
*
* Alle Ein-/Ausgabeoperationen laufen über [Dispatchers.IO], nie auf dem Hauptthread.
*/ */
interface PhotoStorage { interface PhotoStorage {
/** Kopiert das Bild von [sourceUri] in den internen Speicher und liefert den relativen Dateinamen. */
suspend fun copyFrom(sourceUri: Uri): String
/** /**
* Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei und liefert * Importiert ein vom Android Photo Picker ausgewähltes Bild: liest es speicherschonend
* deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue * (Downsampling auf [ImageDownsampler.MAX_STORED_DIMENSION_PX] längster Kante über
* DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das * `BitmapFactory.Options.inSampleSize`, ohne das Originalbild vollständig in den Speicher
* Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen. * zu laden), korrigiert die EXIF-Ausrichtung und speichert das Ergebnis als neue,
* eigenständige JPEG-Datei im internen Speicher. Liefert den relativen Dateinamen
* (ausschließlich der documentationId, s. [resolve]).
*
* Wirft eine Exception, wenn das Bild nicht gelesen/decodiert werden kann. Aufrufer sind
* dafür verantwortlich, das pro Bild abzufangen, damit ein einzelnes fehlerhaftes Bild bei
* Mehrfachauswahl nicht den gesamten Importvorgang abbricht.
*/ */
suspend fun duplicate(fileName: String): String suspend fun importFrom(documentationId: String, sourceUri: Uri): String
/** Löscht die physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */ /**
suspend fun delete(fileName: String) * Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei in einer
* ANDEREN Dokumentation ([targetDocumentationId]). Wird bei "Dokumentation fortsetzen" mit
* `copyPhotos = true` benötigt: Eine neue DB-Zeile, die auf dieselbe Datei wie das Original
* zeigt, wäre falsch - sonst würde Löschen des Fotos in einer Dokumentation es auch in der
* anderen entfernen.
*/
suspend fun duplicate(sourceDocumentationId: String, sourceFileName: String, targetDocumentationId: String): String
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen (z. B. zur Anzeige). */ /** Löscht eine einzelne physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
fun resolve(fileName: String): File suspend fun delete(documentationId: String, fileName: String)
/**
* Löscht das gesamte Fotoverzeichnis einer Dokumentation samt aller enthaltenen Dateien in
* einem Schritt. Verwendet, wenn die komplette Dokumentation gelöscht wird - Room-Cascade
* entfernt zwar die `PhotoEntity`-Zeilen, aber keine Dateien aus `filesDir`, das übernimmt
* ausschließlich diese Methode.
*/
suspend fun deleteAll(documentationId: String)
/**
* Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. Wird von
* MEDIA-001/002 selbst nicht für den Zugriff von außen benötigt (Compose greift nicht
* direkt auf Dateien zu, s. [decodeThumbnail]), steht aber für die spätere PDF-
* Fotoanhang-Funktionalität bereit, ohne diese hier vorwegzunehmen.
*/
fun resolve(documentationId: String, fileName: String): File
/**
* Decodiert eine speicherschonende Vorschauversion (max. [maxDimensionPx] längste Kante)
* der gespeicherten Datei, statt die volle gespeicherte Auflösung zu laden - wichtig, damit
* ein Fotogitter mit vielen Bildern nicht für jede Kachel unnötig viel Speicher belegt.
* Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz, z. B.
* wenn eine Datei nachträglich außerhalb der App entfernt wurde).
*/
suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap?
/**
* Decodiert die gespeicherte Datei proportional eingepasst in eine Box von höchstens
* [maxWidthPx] × [maxHeightPx] Pixeln, OHNE Hochskalieren über die native Auflösung hinaus
* (PDF-004: Fotoanhang). Läuft zweistufig:
*
* 1. Grobe, speicherschonende Decodierung über `BitmapFactory.Options.inSampleSize`
* (Zweierpotenz-Downsampling direkt im Decoder) - liefert ein Bitmap, das mindestens so
* groß wie die exakte Zielgröße ist, aber wegen der Zweierpotenz-Beschränkung von
* `inSampleSize` bei Quellbildern nahe der Zielgröße (z. B. nahe dem Import-Limit
* [ImageDownsampler.MAX_STORED_DIMENSION_PX]) oft noch keine ausreichende Reduktion
* bewirkt.
* 2. Exakte Skalierung dieses groben Zwischenergebnisses auf die tatsächliche Zielgröße
* (`Bitmap.createScaledBitmap`). Das Zwischenergebnis aus Schritt 1 wird direkt danach
* freigegeben (`recycle()`) - das ist unproblematisch, da es zu diesem Zeitpunkt noch
* nirgends (insbesondere nicht an `PdfDocument`) weitergereicht wurde. Nur das Ergebnis
* aus Schritt 2 wird von der aufrufenden Stelle tatsächlich in die PDF gezeichnet.
*
* Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz).
*/
suspend fun decodeFitted(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap?
} }
class InternalPhotoStorage(private val context: Context) : PhotoStorage { class InternalPhotoStorage(private val context: Context) : PhotoStorage {
private val photosDir: File by lazy { private val photosRootDir: File by lazy {
File(context.filesDir, "photos").apply { mkdirs() } File(context.filesDir, "photos").apply { mkdirs() }
} }
override suspend fun copyFrom(sourceUri: Uri): String { private fun documentationDirPath(documentationId: String): File =
File(photosRootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File =
File(documentationDirPath(documentationId), fileName)
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
// 1) Nur die Abmessungen ermitteln (inJustDecodeBounds), ohne Pixel zu laden.
//
// WICHTIG: BitmapFactory.decodeStream(...) liefert bei inJustDecodeBounds = true
// IMMER null zurück (es befüllt nur boundsOptions, gibt aber nie ein Bitmap zurück).
// Die Null-Prüfung muss sich deshalb auf den STREAM selbst beziehen, nicht auf das
// (stets null-wertige) Ergebnis von decodeStream() - sonst würde jeder Import-
// Versuch fälschlich als "Bild konnte nicht geöffnet werden" fehlschlagen, komplett
// unabhängig vom tatsächlichen Bild.
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
val boundsStream = resolver.openInputStream(sourceUri)
?: error("Bild konnte nicht geöffnet werden (Uri nicht mehr gültig?).")
boundsStream.use { input -> BitmapFactory.decodeStream(input, null, boundsOptions) }
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
error("Bildabmessungen konnten nicht ermittelt werden - vermutlich kein unterstütztes Bildformat.")
}
val sampleSize = ImageDownsampler.computeInSampleSize(
boundsOptions.outWidth,
boundsOptions.outHeight,
ImageDownsampler.MAX_STORED_DIMENSION_PX,
)
// 2) Tatsächlich decodieren - bereits mit inSampleSize speicherschonend heruntergerechnet.
// Ein content://-InputStream lässt sich i. d. R. nicht zurückspulen, daher wird die
// Quelle für jeden Schritt separat neu geöffnet.
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
val decoded = resolver.openInputStream(sourceUri)?.use { input ->
BitmapFactory.decodeStream(input, null, decodeOptions)
} ?: error("Bild konnte nicht dekodiert werden.")
// 3) EXIF-Ausrichtung auslesen. Fehlende/nicht lesbare EXIF-Daten führen bewusst zu
// ORIENTATION_NORMAL (keine Korrektur) statt zu einem Fehler - ein Bild ohne EXIF
// ist kein Importfehler.
val orientation = resolver.openInputStream(sourceUri)?.use { input ->
runCatching {
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL
val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation)
// 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis
// speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu
// übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener
// Nebeneffekt, keine bewusste Anforderung dieser Aufgabe.
val targetDir = ensureDocumentationDir(documentationId)
val fileName = "${UUID.randomUUID()}.jpg" val fileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(photosDir, fileName) File(targetDir, fileName).outputStream().use { out ->
context.contentResolver.openInputStream(sourceUri)?.use { input -> corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
targetFile.outputStream().use { output -> input.copyTo(output) }
} ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri")
return fileName
} }
override suspend fun duplicate(fileName: String): String { if (corrected !== decoded) decoded.recycle()
val newFileName = "${UUID.randomUUID()}.jpg" corrected.recycle()
resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false)
return newFileName fileName
} }
override suspend fun delete(fileName: String) { override suspend fun duplicate(
resolve(fileName).delete() sourceDocumentationId: String,
sourceFileName: String,
targetDocumentationId: String,
): String = withContext(Dispatchers.IO) {
val sourceFile = resolve(sourceDocumentationId, sourceFileName)
val targetFileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
sourceFile.copyTo(targetFile, overwrite = false)
targetFileName
} }
override fun resolve(fileName: String): File = File(photosDir, fileName) override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
resolve(documentationId, fileName).delete()
Unit
}
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
documentationDirPath(documentationId).deleteRecursively()
Unit
}
override suspend fun decodeThumbnail(
documentationId: String,
fileName: String,
maxDimensionPx: Int,
): Bitmap? = withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching {
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
val sampleSize = ImageDownsampler.computeInSampleSize(
boundsOptions.outWidth,
boundsOptions.outHeight,
maxDimensionPx,
)
BitmapFactory.decodeFile(
file.absolutePath,
BitmapFactory.Options().apply { inSampleSize = sampleSize },
)
}.getOrNull()
}
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching {
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
val sourceWidth = boundsOptions.outWidth
val sourceHeight = boundsOptions.outHeight
if (sourceWidth <= 0 || sourceHeight <= 0) return@runCatching null
// Zielgröße: proportionale Einpassung in (maxWidthPx x maxHeightPx), Faktor auf
// maximal 1f begrenzt - kein Hochskalieren über die native Auflösung hinaus.
val scale = minOf(
maxWidthPx.toFloat() / sourceWidth,
maxHeightPx.toFloat() / sourceHeight,
1f,
)
val targetWidth = (sourceWidth * scale).roundToInt().coerceAtLeast(1)
val targetHeight = (sourceHeight * scale).roundToInt().coerceAtLeast(1)
// Schritt 1: grobe, speicherschonende Decodierung über inSampleSize - liefert ein
// Bitmap, das mindestens so groß wie (targetWidth, targetHeight) ist.
val inSampleSize = ImageDownsampler.computeInSampleSize(
sourceWidth,
sourceHeight,
maxOf(targetWidth, targetHeight),
)
val decodeOptions = BitmapFactory.Options().apply {
this.inSampleSize = inSampleSize
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val coarseBitmap = BitmapFactory.decodeFile(file.absolutePath, decodeOptions)
?: return@runCatching null
// Schritt 2: exakte Zielgröße. Nur nötig, wenn inSampleSize (Zweierpotenz) nicht
// bereits exakt getroffen hat - insbesondere bei Quellbildern nahe dem
// Import-Limit oft der Fall, da dort inSampleSize = 1 bleibt.
if (coarseBitmap.width == targetWidth && coarseBitmap.height == targetHeight) {
coarseBitmap
} else {
val precise = Bitmap.createScaledBitmap(coarseBitmap, targetWidth, targetHeight, true)
// Das grobe Zwischenergebnis wurde noch nirgends (insbesondere nicht an
// PdfDocument) weitergereicht - Freigabe an dieser Stelle ist unproblematisch.
if (precise !== coarseBitmap) coarseBitmap.recycle()
precise
}
}.getOrNull()
}
} }
@@ -0,0 +1,440 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import android.text.TextPaint
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Konkrete [DocumentationPdfExporter]-Implementierung auf Basis von `android.graphics.pdf.PdfDocument`.
*
* Checkboxen werden bewusst NICHT über Unicode-Zeichen (☒/☐) oder sonstige Font-Glyphen
* dargestellt, sondern ausschließlich über direkte Canvas-Vektorprimitive (`drawRect`/
* `drawLine`) gezeichnet - dadurch ist das PDF geräte- und schriftartunabhängig exakt
* reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert
* ist bzw. welche Unicode-Symbole sie abdeckt.
*
* PDF-004 (Fotoanhang) - wichtige, geprüfte Speicher-Einschränkung von `PdfDocument`: Jede
* `Page` wird intern über ein `SkPicture` AUFGEZEICHNET (verifiziert anhand des AOSP-
* Quellcodes, `frameworks/base/.../PdfDocument.cpp`: `startPage()` -> `mPicture->beginRecording()`,
* `finishPage()` -> nur `endRecording()`, tatsächliches PDF-Rendering inkl. Bild-Einbettung
* erst in `write()`/`writeTo()` über `canvas->drawPicture(*page->mPicture)`). Das bedeutet: JEDE
* über `Canvas.drawBitmap(...)` gezeichnete Bitmap muss bis zum Abschluss von `writeTo()`
* gültig (nicht recycelt) bleiben - ein `Bitmap.recycle()` direkt nach dem Zeichnen einer
* einzelnen Fotoseite ist NICHT sicher. Deshalb werden alle tatsächlich gezeichneten
* Foto-Bitmaps in [export] gesammelt und erst NACH `writeTo()` (im `finally`-Block)
* freigegeben - siehe [retainedPhotoBitmaps].
*
* Der einzige verbleibende Hebel gegen hohen Speicherverbrauch ist deshalb die Zielauflösung
* pro Foto, nicht der Recycling-Zeitpunkt: Zwischenergebnisse, die selbst nie an
* `PdfDocument`/`Canvas.drawBitmap` übergeben werden (grobe `inSampleSize`-Decodierung vor der
* exakten Zielgrößen-Skalierung, s. [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]),
* dürfen dagegen sofort freigegeben werden.
*
* Speicherort: `filesDir/pdf/<documentationId>/<Dateiname>.pdf` - ausschließlich intern, keine
* MediaStore-Nutzung, kein öffentlicher Speicher.
*/
class AndroidPdfDocumentExporter(
private val context: Context,
private val photoStorage: PhotoStorage,
private val signatureStorage: SignatureStorage,
) : DocumentationPdfExporter {
companion object {
// A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm.
private const val PAGE_WIDTH = 595
private const val PAGE_HEIGHT = 842
private const val MARGIN = 40f
private const val TITLE_TEXT_SIZE = 18f
private const val SUBTITLE_TEXT_SIZE = 12f
private const val HEADER_INFO_TEXT_SIZE = 11f
private const val BLOCK_TITLE_TEXT_SIZE = 14f
private const val ITEM_TEXT_SIZE = 11f
private const val FREE_TEXT_TEXT_SIZE = 10.5f
private const val FREE_TEXT_INDENT = 20f
// PDF-003: Signaturbereich. Fachlich handschriftliche Unterschriften, keine
// kryptographische PDF-Signatur (s. Aufgabenstellung Abschnitt 5).
private const val SIGNATURE_LABEL_TEXT_SIZE = 11f
private const val SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE = 11f
private const val SIGNATURE_PLACEHOLDER_TEXT_SIZE = 10.5f
/** Feste Zielbreite des Unterschriftsbilds im PDF (deutlich schmaler als die volle
* Inhaltsbreite - eine Unterschrift muss nicht seitenbreit dargestellt werden). Bei dem
* logischen 1000x400-Signaturformat (Seitenverhältnis 2,5:1, s. Zeichenfläche) ergibt
* das eine Bildhöhe von rund 88pt. */
private const val SIGNATURE_IMAGE_TARGET_WIDTH_PT = 220f
private const val SIGNATURE_LINE_WIDTH_PT = 220f
private const val SIGNATURE_SPACING_AFTER_LABEL = 6f
private const val SIGNATURE_SPACING_AFTER_IMAGE = 4f
private const val SIGNATURE_SPACING_AFTER_LINE = 2f
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
// Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
// der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
private const val ITEM_INDENT = 10f
/**
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
* Speicherverbrauch gewählt (freigegeben nach expliziter Prüfung der `PdfDocument`-
* Speichersemantik, s. Klassendokumentation): untere Grenze des ursprünglich
* vorgeschlagenen 150-200-DPI-Bereichs, da ALLE Foto-Bitmaps bis `writeTo()` gleichzeitig
* im Speicher gehalten werden müssen (keine echte "ein Bitmap zur Zeit"-Verarbeitung mit
* dieser PDF-Engine möglich) und `ARGB_8888` statt `RGB_565` verwendet wird (keine
* Farbtiefenreduktion bei medizinischen Wundfotos).
*/
private const val PHOTO_TARGET_DPI = 150
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
// eingelesen wird), nicht die exakte Zeichenposition (die hängt zusätzlich von der
// jeweils bereits gezeichneten Bildunterschrift ab und wird von PdfPageCursor selbst
// anhand des tatsächlich verbleibenden Platzes bestimmt).
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt()
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt()
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
}
override suspend fun export(model: PdfExportModel): PdfExportResult = withContext(Dispatchers.IO) {
val targetDir = File(File(context.filesDir, "pdf"), model.documentationId)
if (!targetDir.exists() && !targetDir.mkdirs()) {
return@withContext PdfExportResult.Failure(PdfExportError.DirectoryCreationFailed(targetDir.absolutePath))
}
val targetFile = File(targetDir, model.fileName)
// Erst in eine temporäre Datei schreiben und nur bei vollständigem Erfolg auf den
// endgültigen Dateinamen umbenennen - so kann am Zielpfad selbst nie eine halbfertige
// Datei liegen bleiben, auch nicht bei einem Fehler mitten im Schreibvorgang.
val tempFile = File(targetDir, "${model.fileName}.tmp")
val pdfDocument = PdfDocument()
// Sammelt AUSSCHLIESSLICH die Bitmaps, die tatsächlich per Canvas.drawBitmap() gezeichnet
// wurden (nicht die groben inSampleSize-Zwischenergebnisse, die bereits innerhalb von
// PhotoStorage.decodeFitted() freigegeben werden). Diese müssen laut der in der
// Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig
// bleiben und werden deshalb erst im finally-Block unten recycelt.
val retainedPhotoBitmaps = mutableListOf<Bitmap>()
// Signatur-Bitmaps unterliegen exakt derselben PdfDocument-Speichersemantik wie
// Foto-Bitmaps (s. Klassendokumentation) - eigene Liste statt Wiederverwendung von
// [retainedPhotoBitmaps], damit beide Kategorien unabhängig recycelt werden können.
val retainedSignatureBitmaps = mutableListOf<Bitmap>()
try {
val paints = ExportPaints()
val cursor = PdfPageCursor(
pdfDocument = pdfDocument,
pageWidth = PAGE_WIDTH,
pageHeight = PAGE_HEIGHT,
marginLeft = MARGIN,
marginRight = MARGIN,
marginTop = MARGIN,
marginBottom = MARGIN,
)
drawHeader(cursor, model, paints)
for (block in model.blocks) {
drawBlock(cursor, block, paints)
}
// Reihenfolge PDF-003/Abschnitt 25: Dokumentationsblöcke -> Signaturen -> Fotoanhang.
if (model.signatures.isNotEmpty()) {
drawSignatures(cursor, model, paints, retainedSignatureBitmaps)
}
for ((index, photo) in model.photos.withIndex()) {
drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps)
}
cursor.finish()
FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) }
if (targetFile.exists()) targetFile.delete()
if (!tempFile.renameTo(targetFile)) {
tempFile.delete()
return@withContext PdfExportResult.Failure(
PdfExportError.WriteFailed("PDF konnte nicht am endgültigen Speicherort abgelegt werden.")
)
}
PdfExportResult.Success(targetFile)
} catch (e: Exception) {
tempFile.delete()
PdfExportResult.Failure(
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
)
} finally {
pdfDocument.close()
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
// bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist -
// in beiden Fällen werden diese Bitmaps nicht mehr gebraucht.
retainedPhotoBitmaps.forEach { runCatching { it.recycle() } }
retainedSignatureBitmaps.forEach { runCatching { it.recycle() } }
}
}
/** Bündelt alle für den Export verwendeten Paints. */
private class ExportPaints {
val title = TextPaint().apply {
isAntiAlias = true
textSize = TITLE_TEXT_SIZE
isFakeBoldText = true
color = Color.BLACK
}
val subtitle = TextPaint().apply {
isAntiAlias = true
textSize = SUBTITLE_TEXT_SIZE
color = Color.DKGRAY
}
val headerInfo = TextPaint().apply {
isAntiAlias = true
textSize = HEADER_INFO_TEXT_SIZE
color = Color.BLACK
}
val blockTitle = TextPaint().apply {
isAntiAlias = true
textSize = BLOCK_TITLE_TEXT_SIZE
isFakeBoldText = true
color = Color.BLACK
}
val item = TextPaint().apply {
isAntiAlias = true
textSize = ITEM_TEXT_SIZE
color = Color.BLACK
}
val freeText = TextPaint().apply {
isAntiAlias = true
textSize = FREE_TEXT_TEXT_SIZE
color = Color.DKGRAY
}
// PDF-003: Signaturbereich.
val signatureLabel = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_LABEL_TEXT_SIZE
color = Color.BLACK
}
val signatureNameUnderLine = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE
color = Color.BLACK
}
val signaturePlaceholder = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_PLACEHOLDER_TEXT_SIZE
color = Color.DKGRAY
}
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
val photo = Paint().apply {
isAntiAlias = true
isFilterBitmap = true
}
}
private fun drawHeader(cursor: PdfPageCursor, model: PdfExportModel, paints: ExportPaints) {
cursor.drawText("VulneraDoc", paints.title)
cursor.drawText("Wunddokumentation", paints.subtitle, spacingAfter = 8f)
val infoLines = listOf(
"Patient: ${model.patientFullName}",
"Geburtsdatum: ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}",
"Adresse: ${model.patientDisplayAddress}",
"Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}",
)
for ((index, line) in infoLines.withIndex()) {
val spacing = if (index == infoLines.lastIndex) 12f else 2f
cursor.drawText(line, paints.headerInfo, spacingAfter = spacing)
}
cursor.drawDivider(spacingAfter = 12f)
}
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f)
for (item in block.items) {
drawItem(cursor, item, paints)
}
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
}
/** Änderungswunsch: KEIN Checkbox-Symbol mehr im PDF. Da [PdfExportBlock.items] bereits nur
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
val labelText = if (item.isFreeText) "${item.label}:" else item.label
cursor.drawText(
text = labelText,
paint = paints.item,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
// Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
// leerer Freitext erzeugt keinen leeren Bereich im PDF.
if (item.isFreeText && item.freeTextValue.isNotBlank()) {
cursor.drawText(
text = item.freeTextValue,
paint = paints.freeText,
indent = FREE_TEXT_INDENT,
spacingAfter = 6f,
)
}
}
/**
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
* nach orderIndex sortiert, s. [PdfExportModel.signatures]). Anders als der Fotoanhang
* beginnt dieser Abschnitt NICHT zwingend auf einer neuen Seite - er schließt sich direkt an
* die zuletzt gezeichneten Dokumentationsblöcke an, sofern noch Platz vorhanden ist
* (Aufgabenstellung Abschnitt 25/28).
*/
private suspend fun drawSignatures(
cursor: PdfPageCursor,
model: PdfExportModel,
paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>,
) {
cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
for (signature in model.signatures) {
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
}
}
/**
* Zeichnet EINEN Signaturblock (Wundversorgername, Bild bzw. neutraler Platzhalter bei
* beschädigter/fehlender Datei, Unterschriftslinie, ausgeschriebener Name darunter) als
* unteilbare Einheit: Die Gesamthöhe wird VORAB berechnet und über
* [PdfPageCursor.ensureBlockSpace] reserviert, BEVOR die erste Teilzeichnung beginnt - reicht
* der Platz auf der aktuellen Seite nicht, wandert der komplette Block auf die nächste Seite,
* nie nur ein Teil davon (Aufgabenstellung Abschnitt 28).
*
* Bitmap-Lebensdauer: Die dekodierte Signatur-Bitmap wird - genau wie Foto-Bitmaps in
* [drawPhotoPage] - in [retainedSignatureBitmaps] gesammelt und erst nach `writeTo()`
* recycelt (s. Klassendokumentation zur PdfDocument-Speichersemantik).
*/
private suspend fun drawSignatureBlock(
cursor: PdfPageCursor,
model: PdfExportModel,
signature: PdfExportSignature,
paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>,
) {
val labelText = "Wundversorger: ${signature.signerName}"
val nameText = signature.signerName
// Fehlende/beschädigte Signaturdatei führt NICHT zum Absturz des gesamten Exports -
// andere Signaturen und Fotos werden trotzdem exportiert (Aufgabenstellung Abschnitt 31).
// Bewusst OHNE Dateipfad/technischen Fehlertext im Platzhalter.
val bitmap = runCatching {
signatureStorage.decode(documentationId = model.documentationId, fileName = signature.fileName)
}.getOrNull()
val labelHeight = cursor.measureTextHeight(labelText, paints.signatureLabel)
val imageOrPlaceholderHeight = if (bitmap != null) {
(SIGNATURE_IMAGE_TARGET_WIDTH_PT.coerceAtMost(cursor.contentWidth.toFloat())) *
bitmap.height / bitmap.width
} else {
cursor.measureTextHeight("Signatur konnte nicht geladen werden.", paints.signaturePlaceholder)
}
val nameHeight = cursor.measureTextHeight(nameText, paints.signatureNameUnderLine)
val totalBlockHeight = labelHeight + SIGNATURE_SPACING_AFTER_LABEL +
imageOrPlaceholderHeight + SIGNATURE_SPACING_AFTER_IMAGE +
1f + SIGNATURE_SPACING_AFTER_LINE + // Linie selbst ist ca. 1pt "hoch"
nameHeight + SIGNATURE_SPACING_AFTER_BLOCK
// Reserviert die GESAMTE Blockhöhe in einem Rutsch, bevor überhaupt mit dem Zeichnen
// begonnen wird - das ist der eigentliche Mechanismus hinter "atomarer Block" (s.
// Methodenkommentar oben und PdfPageCursor.ensureBlockSpace).
cursor.ensureBlockSpace(totalBlockHeight)
cursor.drawText(labelText, paints.signatureLabel, spacingAfter = SIGNATURE_SPACING_AFTER_LABEL)
if (bitmap != null) {
retainedSignatureBitmaps.add(bitmap)
cursor.drawBitmapFixedWidth(
bitmap = bitmap,
targetWidth = SIGNATURE_IMAGE_TARGET_WIDTH_PT,
paint = paints.photo,
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
)
} else {
cursor.drawText(
"Signatur konnte nicht geladen werden.",
paints.signaturePlaceholder,
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
)
}
cursor.drawShortLine(SIGNATURE_LINE_WIDTH_PT, spacingAfter = SIGNATURE_SPACING_AFTER_LINE)
cursor.drawText(nameText, paints.signatureNameUnderLine, spacingAfter = SIGNATURE_SPACING_AFTER_BLOCK)
}
/**
* Zeichnet den Fotoanhang: jedes Foto auf einer eigenen, neu begonnenen Seite (PDF-004).
* Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt
* zusätzlich eine neutrale Nummerierung ("Foto N von M") - keine Patientendaten oder
* ursprünglichen Dateinamen als Beschriftung.
*
* Erfolgreich decodierte Bitmaps werden in [retainedPhotoBitmaps] gesammelt (s. Begründung
* in der Klassendokumentation zur PdfDocument-Speichersemantik) - der Aufrufer ist dafür
* verantwortlich, diese erst NACH `writeTo()` freizugeben.
*/
private suspend fun drawPhotoPage(
cursor: PdfPageCursor,
model: PdfExportModel,
photo: PdfExportPhoto,
index: Int,
paints: ExportPaints,
retainedPhotoBitmaps: MutableList<Bitmap>,
) {
cursor.forceNewPage()
if (index == 0) {
cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f)
}
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
val bitmap = runCatching {
photoStorage.decodeFitted(
documentationId = model.documentationId,
fileName = photo.fileName,
maxWidthPx = PHOTO_MAX_WIDTH_PX,
maxHeightPx = PHOTO_MAX_HEIGHT_PX,
)
}.getOrNull()
if (bitmap != null) {
retainedPhotoBitmaps.add(bitmap)
cursor.drawBitmapFitted(bitmap, paints.photo)
} else {
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
// bleibt, dass an dieser Stelle ursprünglich ein Foto zur Dokumentation gehörte.
// Bewusst OHNE Dateipfad/technischen Fehlertext.
cursor.drawText("Foto konnte nicht geladen werden.", paints.item)
}
}
}
@@ -0,0 +1,41 @@
package de.medapp.wunddoku.pdf
import java.io.File
/**
* Erzeugt aus einem [PdfExportModel] eine PDF-Datei im internen App-Speicher.
*
* Bewusst vollständig unabhängig von Compose, Navigation, ViewModels und rohen Room-Entities -
* kennt ausschließlich diese schlanke, layoutorientierte Export-Datenstruktur. Als Interface
* mit separater Implementierung ([AndroidPdfDocumentExporter]) gehalten, damit später eine
* PDF-Engine mit Verschlüsselungs-/Signaturunterstützung ergänzt werden kann (siehe
* Aufgabenstellung "Schreibgeschützt"), ohne Repository/ViewModel/UI zu berühren.
*/
interface DocumentationPdfExporter {
suspend fun export(model: PdfExportModel): PdfExportResult
}
/** Klarer Erfolg-/Fehlerpfad statt roher, bis in die UI durchgereichter Exceptions. */
sealed interface PdfExportResult {
data class Success(val file: File) : PdfExportResult
data class Failure(val error: PdfExportError) : PdfExportResult
}
/**
* Fachliche Fehlerkategorien für den PDF-Export.
*
* [DocumentationNotFound] wird NICHT vom Exporter selbst geworfen (der kennt keine
* documentationId, nur das fertige [PdfExportModel]), sondern vom aufrufenden Repository, das
* die Dokumentation vorher lädt - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.exportPdf].
*
* Ein eigener Fehlerfall für "Patientendaten fehlen unerwartet" existiert bewusst NICHT: Da
* [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot] im Domain-Modell
* nicht-nullable ist, macht der Kotlin-Typ diesen Fall bereits zur Kompilierzeit unmöglich - ein
* zusätzlicher Laufzeit-Check wäre toter Code.
*/
sealed interface PdfExportError {
data object DocumentationNotFound : PdfExportError
data class DirectoryCreationFailed(val path: String) : PdfExportError
data class WriteFailed(val message: String) : PdfExportError
data class Unexpected(val message: String) : PdfExportError
}
@@ -0,0 +1,82 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
import java.time.ZoneId
/**
* Wandelt ein [Documentation]-Domain-Modell in das layoutfertige [PdfExportModel] um.
*
* Verwendet für die Filterung ausschließlich die bestehende [BlockEditedPolicy] (keine eigene,
* abweichende Definition von "bearbeitet") und ausschließlich die historischen Snapshot-Felder
* (titleSnapshot/labelSnapshot bzw. deren bereits gemappte Domain-Entsprechungen
* [de.medapp.wunddoku.domain.model.DocumentationBlock.title]/[de.medapp.wunddoku.domain.model.CheckItem.label]) -
* niemals den aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Ebenso werden
* ausschließlich [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot]-Daten
* verwendet, nicht der aktuelle Patientendatensatz - eine spätere Änderung der Patienten-
* Stammdaten darf ein bereits erzeugtes PDF nicht rückwirkend verändern.
*/
object DocumentationToPdfExportModelMapper {
fun map(documentation: Documentation): PdfExportModel {
val createdAtDate = documentation.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
val snapshot = documentation.patientSnapshot
val editedBlocks = documentation.blocks
.filter { BlockEditedPolicy.isEdited(it) }
.sortedBy { it.order }
.map { block ->
PdfExportBlock(
title = block.title,
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
items = block.items
.filter { it.checked }
.sortedBy { it.order }
.map { item ->
PdfExportItem(
label = item.label,
isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue,
)
},
)
}
val fileName = PdfFileNameGenerator.generate(
createdAt = createdAtDate,
firstName = snapshot.firstName,
lastName = snapshot.lastName,
birthDate = snapshot.birthDate,
)
// Explizit erneut nach orderIndex sortiert (nicht implizit auf die bereits im
// Domain-Modell vorhandene Reihenfolge verlassen) - dieselbe defensive Konsistenz wie
// bei blocks/items oben, gemäß Vorgabe "nicht Dateiname/UUID/createdAt/Dateisystem-
// Reihenfolge verwenden".
val photos = documentation.photos
.sortedBy { it.orderIndex }
.map { photo -> PdfExportPhoto(fileName = photo.fileName) }
// Explizit erneut nach orderIndex sortiert (dieselbe defensive Konsistenz wie bei
// blocks/items/photos oben) - alle gespeicherten Signaturen werden exportiert, es gibt
// keine feste Obergrenze und kein `signatures.first()` (Aufgabenstellung Abschnitt 29).
val signatures = documentation.signatures
.sortedBy { it.orderIndex }
.map { signature -> PdfExportSignature(signerName = signature.signerName, fileName = signature.fileName) }
return PdfExportModel(
documentationId = documentation.id,
fileName = fileName,
patientFullName = snapshot.fullName,
patientBirthDate = snapshot.birthDate,
patientDisplayAddress = snapshot.displayAddress,
createdAt = createdAtDate,
blocks = editedBlocks,
signatures = signatures,
photos = photos,
)
}
}
@@ -0,0 +1,63 @@
package de.medapp.wunddoku.pdf
import java.time.LocalDate
/**
* Flache, ausschließlich layoutrelevante Export-Datenstruktur für [DocumentationPdfExporter].
*
* Bewusst getrennt vom Domain-Modell [de.medapp.wunddoku.domain.model.Documentation]: Der
* Exporter soll weder Room-Entities noch domänenspezifische Feinheiten (Snapshot-Mechanik,
* IDs, Status etc.) kennen müssen, sondern ausschließlich das, was tatsächlich auf die Seite
* gezeichnet wird. Die Umwandlung übernimmt [DocumentationToPdfExportModelMapper].
*/
data class PdfExportModel(
val documentationId: String,
val fileName: String,
val patientFullName: String,
val patientBirthDate: LocalDate,
val patientDisplayAddress: String,
val createdAt: LocalDate,
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
val blocks: List<PdfExportBlock>,
/** Bereits sortiert nach orderIndex (PDF-003: Unterschriften). Reihenfolge im fertigen PDF:
* Dokumentationsblöcke -> Signaturen -> Fotoanhang. */
val signatures: List<PdfExportSignature>,
/** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */
val photos: List<PdfExportPhoto>,
)
data class PdfExportBlock(
val title: String,
/** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
* (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
val items: List<PdfExportItem>,
)
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter. */
data class PdfExportItem(
val label: String,
val isFreeText: Boolean,
val freeTextValue: String,
)
/**
* Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] -
* `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über
* [de.medapp.wunddoku.media.PhotoStorage] die intern gespeicherte Datei aufzulösen).
*/
data class PdfExportPhoto(
val fileName: String,
)
/**
* Minimale, für den Signaturbereich benötigte Referenz (PDF-003). [signerName] ist der
* historische Snapshot zum Zeitpunkt der Unterschrift - nicht der aktuelle Stand eventueller
* Wundversorger-Stammdaten. [fileName] wird analog [PdfExportPhoto] über
* [de.medapp.wunddoku.signature.SignatureStorage] aufgelöst (`documentationId` steht bereits am
* [PdfExportModel] zur Verfügung).
*/
data class PdfExportSignature(
val signerName: String,
val fileName: String,
)
@@ -0,0 +1,53 @@
package de.medapp.wunddoku.pdf
import java.text.Normalizer
import java.time.LocalDate
/**
* Zentrale, reine (keine Android-Abhängigkeit, ohne Instrumentierung testbar) Erzeugung des
* vereinbarten PDF-Dateinamensschemas:
*
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`, z. B.
* `2026-08-23_Max_Mustermann_1980-03-14.pdf`
*
* Alle Bestandteile werden dateisystemkompatibel normalisiert (keine Pfadseparatoren, keine
* ungültigen Zeichen, deutsche Umlaute transliteriert statt einfach entfernt).
*/
object PdfFileNameGenerator {
fun generate(createdAt: LocalDate, firstName: String, lastName: String, birthDate: LocalDate): String {
val datePart = createdAt.toString() // LocalDate.toString() liefert ISO-8601 (yyyy-MM-dd)
val birthPart = birthDate.toString()
val firstNamePart = normalizeNameComponent(firstName)
val lastNamePart = normalizeNameComponent(lastName)
return "${datePart}_${firstNamePart}_${lastNamePart}_${birthPart}.pdf"
}
/**
* Normalisiert einen einzelnen Namensbestandteil:
* 1. Deutsche Umlaute/ß werden transliteriert (ä→ae usw.), nicht einfach entfernt - sonst
* würden z. B. "Müller" und "Mller" im Dateinamen ununterscheidbar.
* 2. Verbleibende Diakritika (andere Sprachen) werden über Unicode-Normalisierung entfernt.
* 3. Leerzeichen werden zu Unterstrichen, alles außerhalb `[A-Za-z0-9_-]` wird entfernt -
* schließt insbesondere Pfadseparatoren (`/`, `\`) und sonstige für Dateinamen
* problematische Zeichen sicher aus.
* 4. Mehrfache Unterstriche werden zusammengefasst, führende/nachgestellte entfernt.
*/
private fun normalizeNameComponent(input: String): String {
val transliterated = transliterateGerman(input)
val withoutDiacritics = Normalizer.normalize(transliterated, Normalizer.Form.NFD)
.replace(Regex("\\p{M}+"), "")
val safe = withoutDiacritics
.replace(Regex("\\s+"), "_")
.replace(Regex("[^A-Za-z0-9_-]"), "")
.replace(Regex("_+"), "_")
.trim('_', '-')
return safe.ifBlank { "Unbekannt" }
}
private fun transliterateGerman(input: String): String = input
.replace("ä", "ae").replace("Ä", "Ae")
.replace("ö", "oe").replace("Ö", "Oe")
.replace("ü", "ue").replace("Ü", "Ue")
.replace("ß", "ss")
}
@@ -0,0 +1,89 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Öffnet eine bereits erzeugte PDF-Datei im auf dem Gerät verfügbaren Standard-PDF-Reader
* (PDF-005). Bewusst getrennt von [DocumentationPdfExporter]: PDF-Erzeugung (reine
* Datei-/Domain-Logik) und PDF-Anzeige (Android-Intent/Activity-Start) bleiben unterschiedliche
* Verantwortlichkeiten - der Exporter startet niemals eine Activity.
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen PDF-Viewer; welche konkrete App den
* `ACTION_VIEW`-Intent verarbeitet, entscheidet ausschließlich Android/der Nutzer.
*/
interface PdfOpener {
fun open(file: File): PdfOpenResult
}
sealed interface PdfOpenResult {
data object Success : PdfOpenResult
sealed interface Failure : PdfOpenResult {
/** Die referenzierte Datei existiert nicht mehr (z. B. zwischenzeitlich extern entfernt). */
data object FileNotFound : Failure
/** FileProvider konnte für die Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Kein installierter Reader kann application/pdf verarbeiten. */
data object NoViewerAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
/** Fachliche, für die Anzeige (z. B. Snackbar) geeignete Übersetzung eines [PdfOpenResult.Failure]. */
fun PdfOpenResult.Failure.toUserMessage(): String = when (this) {
PdfOpenResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte die Dokumentation erneut exportieren."
is PdfOpenResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zur Anzeige freigegeben werden."
PdfOpenResult.Failure.NoViewerAvailable ->
"Auf diesem Gerät ist keine App zum Anzeigen von PDF-Dateien installiert."
is PdfOpenResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht geöffnet werden."
}
class AndroidPdfOpener(private val context: Context) : PdfOpener {
override fun open(file: File): PdfOpenResult {
if (!file.exists()) {
return PdfOpenResult.Failure.FileNotFound
}
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, file)
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn die Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfOpenResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
// Ausschließlich Lese-, KEINE Schreibberechtigung für den externen Reader.
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
return try {
context.startActivity(intent)
PdfOpenResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - das würde auf Android 11+
// durch Package-Visibility-Einschränkungen ohne zusätzliche <queries>-Deklaration
// im Manifest fälschlich "kein Reader" melden können, auch wenn tatsächlich einer
// installiert ist. Der eigentliche startActivity()-Aufruf unterliegt dieser
// Einschränkung nicht - ActivityNotFoundException ist hier die zuverlässige Quelle.
PdfOpenResult.Failure.NoViewerAvailable
} catch (e: SecurityException) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Öffnen der PDF-Datei.")
}
}
}
@@ -0,0 +1,301 @@
package de.medapp.wunddoku.pdf
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.pdf.PdfDocument
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
/**
* Verwaltet Seiten, Zeichenfläche und die aktuelle vertikale Schreibposition ("Cursor") beim
* Erzeugen eines [PdfDocument]. Kapselt die komplette Seitenumbruch-Logik an einer Stelle, damit
* [AndroidPdfDocumentExporter] selbst nur noch "was" gezeichnet werden soll beschreiben muss.
*
* SEITENUMBRUCH-STRATEGIE: [drawText] zerlegt einen Text NICHT durch manuelles Verschieben/
* Clippen einzelner Zeilen eines einmal gebauten [StaticLayout]. Stattdessen wird pro
* Seiten-Abschnitt ein EIGENES, frisch aufgebautes [StaticLayout] für genau den Textausschnitt
* erzeugt, der auf die aktuelle Seite passt (Ausschnittgrenzen liegen dabei immer exakt auf
* bereits von Android selbst bestimmten Zeilengrenzen). Jeder Seiten-Abschnitt berechnet damit
* seine Baselines/Zeilenhöhen selbst und korrekt aus der StaticLayout-API - es gibt keine
* manuelle Koordinatenverschiebung einzelner Zeilen, die zu Baseline-/Zeilenhöhenfehlern führen
* könnte. Das erlaubt einen Seitenumbruch exakt zwischen zwei Zeilen und behandelt "Block
* größer als eine Seite" automatisch mit, ohne Sonderfall-Code.
*/
internal class PdfPageCursor(
private val pdfDocument: PdfDocument,
private val pageWidth: Int,
private val pageHeight: Int,
private val marginLeft: Float,
private val marginRight: Float,
private val marginTop: Float,
private val marginBottom: Float,
) {
private var pageNumber = 0
private var currentPage: PdfDocument.Page? = null
private var canvas: Canvas? = null
private var cursorY: Float = marginTop
private val dividerPaint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
}
private val footerPaint = Paint().apply {
color = Color.GRAY
textSize = 9f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
val contentWidth: Int
get() = (pageWidth - marginLeft - marginRight).toInt()
/** Verbleibende Höhe auf der aktuellen Seite ab der aktuellen Cursor-Position (PDF-004:
* Grundlage für die Einpassung des Fotos nach einer bereits gezeichneten Bildunterschrift). */
val remainingHeight: Float
get() {
startIfNeeded()
return (contentBottom() - cursorY).coerceAtLeast(0f)
}
private fun contentBottom(): Float = pageHeight - marginBottom
private fun startIfNeeded() {
if (currentPage == null) startNewPage()
}
private fun startNewPage() {
finishCurrentPageIfOpen()
pageNumber++
val info = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()
val page = pdfDocument.startPage(info)
currentPage = page
canvas = page.canvas
cursorY = marginTop
}
private fun finishCurrentPageIfOpen() {
val page = currentPage ?: return
// Seitenzahl wird direkt beim Fertigstellen dieser Seite gezeichnet (kein "Seite X von
// Y", da die Gesamtseitenzahl an dieser Stelle noch nicht bekannt ist).
canvas?.drawText("Seite $pageNumber", pageWidth / 2f, pageHeight - marginBottom / 2f, footerPaint)
pdfDocument.finishPage(page)
currentPage = null
canvas = null
}
/**
* Erzwingt den Beginn einer neuen Seite, unabhängig davon, wie viel Platz auf der aktuellen
* Seite noch verfügbar wäre (PDF-004: jedes Foto beginnt zwingend auf einer eigenen neuen
* Seite, nie im verbleibenden Platz einer Dokumentations- oder vorherigen Fotoseite). Die
* Seitennummerierung läuft dabei fortlaufend weiter, da [startNewPage] intern lediglich
* `pageNumber` erhöht, nie zurücksetzt.
*/
fun forceNewPage() {
startNewPage()
}
/**
* Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal
* über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der
* Seite. Setzt voraus, dass die Seite bereits begonnen wurde (i. d. R. direkt nach
* [forceNewPage] plus einer optionalen Bildunterschrift über [drawText]) - diese Methode
* selbst löst KEINEN Seitenumbruch aus, da ein Foto nicht über zwei Seiten verteilt werden
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
* hineinskaliert (kleiner, nie abgeschnitten).
*/
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) {
startIfNeeded()
val availableWidth = contentWidth.toFloat()
val availableHeight = remainingHeight
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height)
val drawWidth = bitmap.width * scale
val drawHeight = bitmap.height * scale
val left = marginLeft + (availableWidth - drawWidth) / 2f
val top = cursorY + (availableHeight - drawHeight) / 2f
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY = contentBottom()
}
/** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal
* aufgerufen werden. */
fun finish() {
finishCurrentPageIfOpen()
}
fun drawDivider(spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
startIfNeeded()
ensureSpace(spacingBefore + 1f)
cursorY += spacingBefore
canvas!!.drawLine(marginLeft, cursorY, pageWidth - marginRight, cursorY, dividerPaint)
cursorY += 1f + spacingAfter
}
/** Reserviert [height] vertikalen Platz, beginnt bei Bedarf eine neue Seite. */
private fun ensureSpace(height: Float) {
startIfNeeded()
if (cursorY + height > contentBottom()) {
startNewPage()
}
}
/**
* Öffentliches Gegenstück zu [ensureSpace] für ATOMARE Blöcke, die aus mehreren
* Einzelzeichnungen bestehen (PDF-003: ein Signaturblock aus Wundversorgername, Bild, Linie
* und Namenszeile darf laut Aufgabenstellung Abschnitt 28 nicht zwischen zwei Seiten
* zerrissen werden). Der Aufrufer berechnet VOR dem eigentlichen Zeichnen die
* Gesamthöhe des Blocks (s. [measureTextHeight] für die enthaltenen Textanteile) und
* reserviert sie hier in einem Rutsch - reicht der verbleibende Platz nicht, beginnt eine
* komplett neue Seite, BEVOR auch nur die erste Teilzeichnung des Blocks beginnt. Anders als
* [drawText] (das einen zu langen Text selbst auf mehrere Seiten verteilen würde) gibt es
* hier keine interne Aufteilung - das ist hier gewünscht, kein Sonderfall.
*/
fun ensureBlockSpace(height: Float) {
ensureSpace(height)
}
/**
* Berechnet die Gesamthöhe, die [text] beim Zeichnen mit [drawText] belegen würde, OHNE
* etwas zu zeichnen oder den Cursor zu verändern. Verwendet zur Berechnung dasselbe
* [buildLayout] wie [drawText] selbst (identischer Text/Paint/Breite), damit die hier
* gemessene und die später tatsächlich gezeichnete Höhe für ein und denselben Aufruf
* garantiert übereinstimmen - Voraussetzung dafür, dass eine vorab über [ensureBlockSpace]
* reservierte Blockhöhe beim tatsächlichen Zeichnen exakt ausreicht.
*/
fun measureTextHeight(text: String, paint: TextPaint, indent: Float = 0f): Float {
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
return buildLayout(text, paint, width).height.toFloat()
}
/**
* Zeichnet [bitmap] proportional in eine FESTE Zielbreite [targetWidth] eingepasst (Höhe
* ergibt sich aus dem Seitenverhältnis von [bitmap] - kein Zuschneiden, keine Verzerrung),
* linksbündig ab der aktuellen Cursor-Position.
*
* Bewusst ein GRUNDSÄTZLICH ANDERES Verhalten als [drawBitmapFitted] (PDF-004: füllt gezielt
* den GESAMTEN verbleibenden Platz der Seite - richtig für "genau ein Foto pro Seite"): Hier
* wird NICHT der restliche Seitenplatz ausgefüllt, sondern exakt die aus [targetWidth]
* abgeleitete Zielhöhe gezeichnet und der Cursor auch nur genau um diese Höhe
* weiterbewegt - Voraussetzung dafür, dass mehrere Signaturblöcke hintereinander auf
* derselben Seite stehen können, statt dass jeder einzelne Block wie ein Foto seine ganze
* Seite beansprucht (Aufgabenstellung Abschnitt 28: "Mehrere vollständige Signaturblöcke
* dürfen auf derselben Seite stehen, sofern genug Platz vorhanden ist").
*
* Setzt voraus, dass für den vollständigen Block (inkl. dieses Bildes) bereits vorher
* [ensureBlockSpace] aufgerufen wurde - diese Methode selbst löst KEINEN Seitenumbruch aus.
*/
fun drawBitmapFixedWidth(bitmap: Bitmap, targetWidth: Float, paint: Paint, spacingAfter: Float = 0f): Float {
startIfNeeded()
if (bitmap.width <= 0 || bitmap.height <= 0) return 0f
val drawWidth = targetWidth.coerceAtMost(contentWidth.toFloat())
val drawHeight = drawWidth * bitmap.height / bitmap.width
val left = marginLeft
val top = cursorY
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY += drawHeight + spacingAfter
return drawHeight
}
/**
* Kurze horizontale Linie fester Breite [width] (z. B. eine Unterschriftslinie), linksbündig
* ab der aktuellen Cursor-Position - im Gegensatz zu [drawDivider] NICHT über die volle
* Inhaltsbreite der Seite gezogen. Setzt wie [drawBitmapFixedWidth] ein vorheriges
* [ensureBlockSpace] für den gesamten umgebenden Block voraus.
*/
fun drawShortLine(width: Float, spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
startIfNeeded()
cursorY += spacingBefore
canvas!!.drawLine(marginLeft, cursorY, marginLeft + width, cursorY, dividerPaint)
cursorY += spacingAfter
}
/**
* Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert
* über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach
* rechts (z. B. für eingerückten Freitext unter einer Checkbox-Zeile).
*
* [leadingMark], falls gesetzt, wird EINMALIG unmittelbar vor der allerersten gezeichneten
* Textzeile aufgerufen - Aufrufparameter sind (Canvas, linke X-Position vor dem Einzug,
* Geräte-Y-Oberkante, Geräte-Y-Unterkante) der ersten Zeile. Dient z. B. dazu, links neben
* dem Text ein Checkbox-Symbol zu zeichnen (s. [AndroidPdfDocumentExporter]). Kästchen und
* erste Zeile werden dabei als untrennbare Einheit behandelt: Reicht der Platz auf der
* aktuellen Seite nicht einmal für die erste Zeile, wird vorher auf eine neue Seite
* gewechselt - das Kästchen kann also niemals von seiner Zeile getrennt auf eine andere
* Seite geraten.
*/
fun drawText(
text: String,
paint: TextPaint,
indent: Float = 0f,
spacingAfter: Float = 0f,
leadingMark: ((canvas: Canvas, left: Float, top: Float, bottom: Float) -> Unit)? = null,
) {
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
val fullLayout = buildLayout(text, paint, width)
var lineIndex = 0
val lineCount = fullLayout.lineCount
var isFirstLine = true
while (lineIndex < lineCount) {
startIfNeeded()
val remaining = contentBottom() - cursorY
// Ermittelt, wie viele aufeinanderfolgende Zeilen ab lineIndex noch auf die
// aktuelle Seite passen (Ausschnittgrenzen liegen exakt auf Zeilengrenzen).
var endLineExclusive = lineIndex
var usedHeight = 0f
while (endLineExclusive < lineCount) {
val lineHeight = (fullLayout.getLineBottom(endLineExclusive) - fullLayout.getLineTop(endLineExclusive)).toFloat()
if (usedHeight + lineHeight > remaining) break
usedHeight += lineHeight
endLineExclusive++
}
if (endLineExclusive == lineIndex) {
// Nicht einmal eine einzige Zeile passt mehr auf die aktuelle Seite -> neue
// Seite beginnen und denselben Ausschnitt erneut versuchen.
startNewPage()
continue
}
val charStart = fullLayout.getLineStart(lineIndex)
val charEnd = fullLayout.getLineEnd(endLineExclusive - 1)
// Frisches StaticLayout NUR für diesen Seiten-Abschnitt - Android berechnet Zeilen/
// Baselines dafür vollständig neu, beginnend bei lokal y = 0 für die erste Zeile
// dieses Abschnitts. Keine manuelle Koordinatenübernahme aus fullLayout nötig.
val chunkLayout = buildLayout(text.substring(charStart, charEnd), paint, width)
val c = canvas!!
c.save()
c.translate(marginLeft + indent, cursorY)
chunkLayout.draw(c)
c.restore()
if (isFirstLine && leadingMark != null) {
val firstLineHeight = (chunkLayout.getLineBottom(0) - chunkLayout.getLineTop(0)).toFloat()
leadingMark(c, marginLeft, cursorY, cursorY + firstLineHeight)
isFirstLine = false
}
cursorY += usedHeight
lineIndex = endLineExclusive
}
cursorY += spacingAfter
}
private fun buildLayout(text: String, paint: TextPaint, width: Int): StaticLayout {
val safeWidth = width.coerceAtLeast(1)
return StaticLayout.Builder.obtain(text, 0, text.length, paint, safeWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0f, 1.05f)
.setIncludePad(false)
.build()
}
}
@@ -0,0 +1,29 @@
package de.medapp.wunddoku.signature
import android.graphics.Bitmap
/**
* Zweite, von der UI unabhängige Absicherung gegen das Speichern einer leeren Signatur
* (Aufgabenstellung Abschnitt 12: "Daten-/Storage-Ebene: keine leere PNG-Datei als gültige
* Signatur persistieren"). Die erste Ebene ist der deaktivierte "Speichern"-Button in der UI,
* solange keine Striche gezeichnet wurden - diese Prüfung greift zusätzlich unabhängig davon in
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature], damit ein Aufruf,
* der die UI-Absicherung umgeht (Programmierfehler, zukünftiger zweiter Aufrufer o. Ä.), nicht
* trotzdem eine leere Signatur persistiert.
*/
object SignatureBitmapValidation {
/**
* Liefert `true`, wenn [bitmap] ausschließlich reinweiße Pixel enthält, also keine
* tatsächlich gezeichnete Signatur darstellt. Die Signaturzeichenfläche rendert auf einen
* weißen Hintergrund; jeder gezeichnete Strich (auch nur teilweise, dank Anti-Aliasing an
* den Rändern) erzeugt mindestens einen Pixel, der von reinem Weiß abweicht.
*/
fun isBlank(bitmap: Bitmap): Boolean {
if (bitmap.width <= 0 || bitmap.height <= 0) return true
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val opaqueWhite = 0xFFFFFFFF.toInt()
return pixels.none { pixel -> pixel != opaqueWhite }
}
}
@@ -0,0 +1,132 @@
package de.medapp.wunddoku.signature
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
/**
* Verwaltet die physischen Signatur-PNGs im internen App-Speicher, organisiert pro
* Dokumentation - exakt dieselbe Struktur-Idee wie
* [de.medapp.wunddoku.media.PhotoStorage]:
*
* ```
* filesDir/
* └── signatures/
* └── <documentationId>/
* └── <signatureId>.png
* ```
*
* Ersetzt den ursprünglichen Platzhalter `SignatureFileStorage`, der noch flach unter
* `filesDir/signatures/<uuid>.png` speicherte (ohne Bezug zur jeweiligen Dokumentation). Ein
* eigenes Unterverzeichnis pro Dokumentation hat denselben Vorteil wie bei Fotos: [deleteAll]
* kann beim Löschen einer kompletten Dokumentation das gesamte Verzeichnis in einem Schritt
* entfernen, statt Einzeldateien anhand von DB-Zeilen durchzugehen (Aufgabenstellung
* Abschnitt 13/18).
*
* Persistiert wird ausschließlich das fertige, bereits auf das logische 1000x400-Format
* normalisierte Signaturbild als PNG (verlustfrei) - Stroke-/Pfad-Daten sind ausschließlich
* Editor-State und werden hier nie entgegengenommen (Aufgabenstellung Abschnitt 11). Alle
* Operationen laufen über [Dispatchers.IO], nie auf dem Hauptthread.
*/
interface SignatureStorage {
/**
* Speichert [bitmap] als neue PNG-Datei im dokumentationsspezifischen Verzeichnis. Liefert
* den relativen Dateinamen (ausschließlich der documentationId, s. [resolve]) - kein
* absoluter, geräteabhängiger Pfad wird persistiert (Aufgabenstellung Abschnitt 15/20).
*/
suspend fun save(documentationId: String, bitmap: Bitmap): String
/** Löscht eine einzelne physische Signaturdatei. Wirft KEINE Exception, wenn die Datei
* nicht (mehr) existiert - analog [de.medapp.wunddoku.media.PhotoStorage.delete]. */
suspend fun delete(documentationId: String, fileName: String)
/**
* Löscht das gesamte Signaturverzeichnis einer Dokumentation samt aller enthaltenen Dateien
* in einem Schritt (analog [de.medapp.wunddoku.media.PhotoStorage.deleteAll]). Verwendet
* beim Löschen der kompletten Dokumentation - unabhängig davon, welche DB-Zeilen zu diesem
* Zeitpunkt noch bekannt sind, damit keine verwaisten PNG-Dateien zurückbleiben.
*/
suspend fun deleteAll(documentationId: String)
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. */
fun resolve(documentationId: String, fileName: String): File
/**
* Decodiert die gespeicherte PNG-Datei in voller Auflösung. Kein Downsampling nötig - das
* logische Signaturformat ist mit 1000x400px bereits klein (siehe Zeichenfläche), anders
* als importierte Fotos. Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden
* kann - PDF-003 (Aufgabenstellung Abschnitt 31) zeigt in diesem Fall einen neutralen
* Platzhalter statt abzustürzen.
*/
suspend fun decode(documentationId: String, fileName: String): Bitmap?
}
/**
* Generische Implementierung, organisiert pro "Schlüssel" (bei dokumentationsbezogenen
* Signaturen: `documentationId`) in einem eigenen Wurzelverzeichnis [rootDirName]. Wird
* ZWEIMAL instanziiert (s. [de.medapp.wunddoku.di.AppContainer]):
*
* - einmal für die historischen, unveränderlichen Signaturen einzelner Dokumentationen
* (`rootDirName = "signatures"`, Schlüssel = documentationId),
* - einmal für das Wundversorger-Register mit jeweils nur der AKTUELLSTEN Signatur je Person
* (`rootDirName = "wound_carer_signatures"`, Schlüssel = woundCarerId) - s.
* [de.medapp.wunddoku.data.repository.WoundCarerRepository].
*
* Beide Verwendungen sind vom Namen her identisch aufgebaut (`filesDir/<rootDirName>/<key>/`),
* aber vollständig unabhängige Verzeichnisbäume - eine gelöschte Dokumentation räumt z. B. NIE
* das Wundversorger-Register mit auf, und umgekehrt.
*/
class InternalSignatureStorage(
private val context: Context,
private val rootDirName: String = "signatures",
) : SignatureStorage {
private val rootDir: File by lazy {
File(context.filesDir, rootDirName).apply { mkdirs() }
}
private fun documentationDirPath(documentationId: String): File =
File(rootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File =
File(documentationDirPath(documentationId), fileName)
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
withContext(Dispatchers.IO) {
val fileName = "${UUID.randomUUID()}.png"
val targetDir = ensureDocumentationDir(documentationId)
FileOutputStream(File(targetDir, fileName)).use { out ->
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
// verlangt.
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
fileName
}
override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
resolve(documentationId, fileName).delete()
Unit
}
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
documentationDirPath(documentationId).deleteRecursively()
Unit
}
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull()
}
}
@@ -1,44 +1,96 @@
package de.medapp.wunddoku.ui.documentation.edit package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.size
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
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.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.collectAsStateWithLifecycle 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.DocumentationBlock import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
/** /**
* Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2, * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
* Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und * Vor-/Zurück-Buttons und Positionsanzeige ("7 / 20"), darunter dokumentationsweit (nicht
* Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen * blockbezogen) den Fotobereich (MEDIA-001/002). Die von der Aufgabenstellung bevorzugte
* Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5. * Swipe-Geste zwischen Blöcken sowie Signatur/PDF-Export folgen in späteren Phasen.
*
* Der Inhalt ist als [LazyColumn] aufgebaut (statt eines einzelnen `Column` mit
* `verticalScroll`), damit Foto-Vorschaubilder nur decodiert werden, wenn ihre Zeile tatsächlich
* sichtbar ist/wird - wichtig, da die Anzahl der Fotos einer Dokumentation nicht künstlich
* begrenzt werden soll (Aufgabenstellung MEDIA-002) und jedes zusätzliche gleichzeitig
* gehaltene Vorschaubild RAM kostet.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -48,10 +100,16 @@ fun DocumentationEditScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Vollbildansicht eines Fotos beim Antippen einer Kachel (Änderungswunsch) - rein lokaler
// UI-Zustand, bewusst nicht im ViewModel gehalten (analog zu den transient geladenen
// Thumbnail-/Signatur-Bitmaps, s. Kommentare an PhotoThumbnail/loadPhotoThumbnail): welches
// Foto gerade großformatig angezeigt wird, ist reine Präsentationslogik ohne fachliche
// Bedeutung, die einen Prozesstod überdauern müsste.
var viewedPhoto by remember { mutableStateOf<Photo?>(null) }
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren // Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
// Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche // Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
// Absicherung für den häufigen Fall.
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -63,15 +121,52 @@ fun DocumentationEditScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
} }
// Android Photo Picker (MEDIA-001): benötigt KEINE READ_MEDIA_IMAGES-/
// READ_EXTERNAL_STORAGE-Berechtigung, da das System die Auswahl übernimmt und der App nur
// Zugriff auf die konkret gewählten Bilder gewährt. `PickMultipleVisualMedia` erlaubt
// Mehrfachauswahl, `ImageOnly` beschränkt die Auswahl auf Bilder. Bricht der Nutzer den
// Picker ohne Auswahl ab, liefert der Callback eine leere Liste - das wird im ViewModel
// (onPhotosPicked) als No-Op behandelt, nicht als Fehler.
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(),
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
// Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler
// "ANZEIGEN"-Aktion (PDF-005). PdfOpener wird bewusst hier in der Compose-Schicht instanziiert
// (mit dem Activity-Context aus LocalContext, analog zum Photo-Picker-Launcher oben) - das
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
val context = LocalContext.current
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
val exportedFile = uiState.lastExportedPdfFile
// "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte
// Datei vorliegt - bei einem fehlgeschlagenen Export ist exportedFile null und die
// Snackbar zeigt keine Aktion an.
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = if (exportedFile != null) "ANZEIGEN" else null,
)
if (result == SnackbarResult.ActionPerformed && exportedFile != null) {
when (val openResult = pdfOpener.open(exportedFile)) {
PdfOpenResult.Success -> Unit
is PdfOpenResult.Failure -> snackbarHostState.showSnackbar(openResult.toUserMessage())
}
}
viewModel.dismissPdfExportMessage()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
if (uiState.blockCount > 0) { if (uiState.blockCount > 0) {
Text( Text(
stringResource( stringResource(
R.string.documentation_edit_position, R.string.documentation_edit_progress,
uiState.currentBlockIndex + 1, uiState.editedBlockCount,
uiState.blockCount, uiState.blockCount,
) )
) )
@@ -80,9 +175,18 @@ fun DocumentationEditScreen(
} }
}, },
actions = { actions = {
// PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die // PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt
// Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave // ausschließlich die Datei im internen Speicher (PDF-001/002), als
// gesichert, kein expliziter Speichern-Button nötig). // minimaler Trigger zum manuellen Testen der Export-Funktionalität.
if (uiState.pdfExportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = viewModel::exportPdf) {
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
}
}
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
// Autosave gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") } Button(onClick = onFinished) { Text("Fertig") }
}, },
) )
@@ -101,70 +205,491 @@ fun DocumentationEditScreen(
else -> { else -> {
val documentation = uiState.documentation val documentation = uiState.documentation
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) if (documentation == null || documentation.blocks.isEmpty()) {
if (documentation == null || currentBlock == null) {
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Diese Dokumentation enthält keine Blöcke.") Text("Diese Dokumentation enthält keine Blöcke.")
} }
} else { } else {
DocumentationBlockContent( DocumentationEditContent(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock, blocks = documentation.blocks,
expandedBlockId = uiState.expandedBlockId,
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
photos = documentation.photos,
signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged, onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged, onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0, photoImportInProgress = uiState.photoImportInProgress,
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1, photoImportError = uiState.photoImportError,
onPrevious = viewModel::onPreviousBlock, onDismissPhotoImportError = viewModel::dismissPhotoImportError,
onNext = viewModel::onNextBlock, onAddPhotosClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it },
onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap,
) )
} }
} }
} }
} }
val pendingDeletePhotoId = uiState.pendingDeletePhotoId
if (pendingDeletePhotoId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeletePhoto,
title = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeletePhoto) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeletePhoto) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene
// Navigationsroute, s. Aufgabenstellung Abschnitt 7).
if (uiState.showAddSignatureDialog) {
AddSignatureDialog(
onDismiss = viewModel::dismissAddSignature,
onSave = viewModel::saveSignature,
loadDefaultWoundCarer = viewModel::getDefaultWoundCarer,
searchWoundCarers = viewModel::searchWoundCarers,
loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap,
)
}
// Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung
// Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher
// deaktiviert, solange Name oder Zeichnung leer sind.
val signatureError = uiState.signatureError
if (signatureError != null) {
AlertDialog(
onDismissRequest = viewModel::dismissSignatureError,
title = { Text(stringResource(R.string.documentation_edit_signatures_title)) },
text = { Text(signatureError) },
confirmButton = {
TextButton(onClick = viewModel::dismissSignatureError) {
Text(stringResource(R.string.action_confirm))
}
},
)
}
val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId
if (pendingDeleteSignatureId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeleteSignature,
title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeleteSignature) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteSignature) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// Änderungswunsch: Foto in Originalgröße anzeigen.
val photoToView = viewedPhoto
if (photoToView != null) {
PhotoViewerDialog(
photo = photoToView,
loadFullSize = viewModel::loadPhotoFullSize,
onDismiss = { viewedPhoto = null },
)
}
}
/**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
* (Änderungswunsch) - zunächst stehen nur die Überschriften untereinander, Antippen einer
* Überschrift klappt genau diesen Block auf und schließt dabei automatisch einen zuvor
* aufgeklappten (s. [DocumentationEditViewModel.toggleBlockExpanded] - IMMER höchstens ein
* Block gleichzeitig offen) - gefolgt vom dokumentationsweiten Signatur- und Fotobereich. Als
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
* sind/werden.
*/
@Composable
private fun DocumentationEditContent(
modifier: Modifier = Modifier,
blocks: List<DocumentationBlock>,
expandedBlockId: String?,
onToggleBlockExpanded: (blockStateId: String) -> Unit,
photos: List<Photo>,
signatures: List<Signature>,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
photoImportInProgress: Boolean,
photoImportError: String?,
onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onPhotoClick: (Photo) -> Unit,
onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(blocks, key = { it.blockStateId }) { block ->
BlockAccordionItem(
block = block,
expanded = block.blockStateId == expandedBlockId,
onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
}
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
// darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im
// späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003).
item(key = "signatures_header") {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.documentation_edit_signatures_title),
style = MaterialTheme.typography.titleMedium,
)
OutlinedButton(onClick = onAddSignatureClick) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.documentation_edit_add_signature))
}
}
}
}
if (signatures.isEmpty()) {
item(key = "signatures_empty") {
Text(
stringResource(R.string.documentation_edit_no_signatures),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
items(signatures, key = { it.id }) { signature ->
SignatureRow(
signature = signature,
loadBitmap = loadSignatureBitmap,
onDeleteRequested = { onDeleteSignatureRequested(signature.id) },
)
}
}
item(key = "photos_header") {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(R.string.documentation_edit_photos_title), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (photoImportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.documentation_edit_add_photos))
}
}
}
}
}
if (photoImportError != null) {
item(key = "photo_import_error") {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
photoImportError,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismissPhotoImportError) { Text("OK") }
}
}
}
if (photos.isEmpty()) {
item(key = "photos_empty") {
Text(
stringResource(R.string.documentation_edit_no_photos),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
PhotoRow(
photos = row,
loadThumbnail = loadThumbnail,
onDeleteRequested = onDeletePhotoRequested,
onPhotoClick = onPhotoClick,
)
}
}
}
} }
private const val PHOTO_GRID_COLUMNS = 3
@Composable @Composable
private fun DocumentationBlockContent( private fun PhotoRow(
modifier: Modifier = Modifier, photos: List<Photo>,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: (photoId: String) -> Unit,
onPhotoClick: (Photo) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo ->
PhotoThumbnail(
photo = photo,
loadThumbnail = loadThumbnail,
onDeleteRequested = { onDeleteRequested(photo.id) },
onClick = { onPhotoClick(photo) },
)
}
}
}
/**
* Eine einzelne Foto-Kachel im Fotogitter. Lädt ihre Vorschauversion selbst, transient über
* [LaunchedEffect]/`remember` (an `photo.id` gebunden) - das entstehende [Bitmap] wird
* ausschließlich lokal in dieser Composable gehalten, nicht im zentralen ViewModel-Zustand
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
* sichtbar ist), wird der Zustand automatisch freigegeben.
*
* Ein Antippen der Kachel (außerhalb des Löschen-Buttons) öffnet die Vollbildansicht
* (Änderungswunsch) - [onClick] statt eines eigenen Ladevorgangs hier, da die Kachel selbst nur
* das kleine, bereits geladene Thumbnail kennt.
*/
@Composable
private fun PhotoThumbnail(
photo: Photo,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
onClick: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
LaunchedEffect(photo.id) {
val result = runCatching { loadThumbnail(photo.documentationId, photo.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Box(
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = bitmap != null, onClick = onClick),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
loadFailed -> Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
)
else -> CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
strokeWidth = 2.dp,
)
}
IconButton(
onClick = onDeleteRequested,
modifier = Modifier.align(Alignment.TopEnd).size(28.dp),
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
tint = Color.White,
)
}
}
}
/**
* Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und
* Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre
* Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) -
* analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel.
* Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es
* hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen.
*/
@Composable
private fun SignatureRow(
signature: Signature,
loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
) {
var bitmap by remember(signature.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(signature.id) { mutableStateOf(false) }
LaunchedEffect(signature.id) {
val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.width(96.dp)
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
loadFailed -> Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
)
else -> CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
strokeWidth = 2.dp,
)
}
}
Text(
text = signature.signerName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDeleteRequested) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
)
}
}
}
/**
* Ein einzelner Block im Akkordeon (Änderungswunsch): eine antippbare Kopfzeile mit Titel,
* einem kleinen "bearbeitet"-Indikator (grüner Haken, s. [DocumentationBlock.isEdited]) und
* einem Pfeil-Icon, das den Auf-/Zugeklappt-Zustand anzeigt. Der eigentliche Inhalt (Checkboxen
* inkl. Freitext-Option) wird ausschließlich gerendert, wenn [expanded] `true` ist - das
* entspricht fachlich einem Dropdown/Expansion-Panel, technisch einfach bedingtes Komponieren
* ohne eigene Lazy-Liste (ein Block hat nur wenige Optionen).
*/
@Composable
private fun BlockAccordionItem(
block: DocumentationBlock, block: DocumentationBlock,
expanded: Boolean,
onHeaderClick: () -> Unit,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean,
canGoNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
) { ) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onHeaderClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (block.isEdited) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.documentation_edit_block_edited),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
}
Text(
block.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
)
}
if (expanded) {
Column( Column(
modifier = modifier modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 8.dp),
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text(block.title, style = MaterialTheme.typography.headlineSmall) for (item in block.items) {
when {
block.items.forEach { item -> item.isStaticHint -> StaticHintRow(item = item)
if (item.isFreeText) { item.isFreeText -> FreeTextCheckItemRow(
FreeTextCheckItemRow(
item = item, item = item,
onCheckedChanged = onCheckedChanged, onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged, onFreeTextChanged = onFreeTextChanged,
) )
} else { else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) }
}
} }
} }
Row( HorizontalDivider()
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
Text(stringResource(R.string.documentation_edit_previous))
}
Button(onClick = onNext, enabled = canGoNext) {
Text(stringResource(R.string.documentation_edit_next))
}
}
} }
} }
@@ -183,10 +708,9 @@ private fun CheckItemRow(
} }
/** /**
* Die Freitext-Option eines Blocks (Änderungswunsch: Freitext als weitere auswählbare Option * Die Freitext-Option eines Blocks. Das Textfeld ist deaktiviert, solange die zugehörige
* statt eigenes Feld am Block). Das Textfeld ist deaktiviert, solange die zugehörige Checkbox * Checkbox nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option
* nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option auch als * auch als "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
* "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
*/ */
@Composable @Composable
private fun FreeTextCheckItemRow( private fun FreeTextCheckItemRow(
@@ -212,3 +736,18 @@ private fun FreeTextCheckItemRow(
) )
} }
} }
/**
* Ein rein anzeigender, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" bei "Empfohlene
* Wundversorgung") - keine Checkbox, keine Interaktion, [item.label] ist direkt der
* anzuzeigende Text selbst. Optisch dezent (kursiv, gedämpfte Farbe) abgesetzt von echten,
* ausfüllbaren Optionen, damit auf den ersten Blick klar ist, dass hier nichts einzugeben ist.
*/
@Composable
private fun StaticHintRow(item: CheckItem) {
Text(
item.label,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -1,5 +1,7 @@
package de.medapp.wunddoku.ui.documentation.edit package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -7,8 +9,12 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.toRoute import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.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
@@ -18,14 +24,54 @@ 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 java.io.File
data class DocumentationEditUiState( data class DocumentationEditUiState(
val documentation: Documentation? = null, val documentation: Documentation? = null,
val currentBlockIndex: Int = 0, /** ID des BlockState, der gerade im Akkordeon aufgeklappt ist - `null`, wenn keiner
* aufgeklappt ist (Änderungswunsch: zunächst nur alle Überschriften untereinander, Antippen
* einer Überschrift klappt sie auf). Es ist IMMER höchstens ein Block gleichzeitig
* aufgeklappt, s. [DocumentationEditViewModel.toggleBlockExpanded]. */
val expandedBlockId: String? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
val photoImportInProgress: Boolean = false,
/** Zusammenfassende Fehlermeldung, falls beim letzten Importvorgang einzelne Bilder
* fehlgeschlagen sind - erfolgreich importierbare Bilder werden trotzdem übernommen. */
val photoImportError: String? = null,
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
val pendingDeletePhotoId: String? = null,
/** true, solange der "Signatur hinzufügen"-Dialog angezeigt wird (MEDIA-003). */
val showAddSignatureDialog: Boolean = false,
/** Fehlermeldung aus einer fehlgeschlagenen Signatur-Validierung (zweite, von der UI
* unabhängige Absicherungsebene in [DocumentationRepository.saveSignature] - im Normalfall
* verhindert bereits der deaktivierte "Speichern"-Button im Dialog diesen Fall). */
val signatureError: String? = null,
/** != null, solange die Löschbestätigung für eine Signatur angezeigt werden soll. */
val pendingDeleteSignatureId: String? = null,
/** true, während ein PDF-Export läuft (Ladeindikator, Button währenddessen deaktiviert). */
val pdfExportInProgress: Boolean = false,
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
val pdfExportMessage: String? = null,
/**
* Die zuletzt erfolgreich erzeugte PDF-Datei dieser Dokumentation (PDF-005: "ANZEIGEN" in
* der Snackbar). `null` sowohl vor dem ersten Export als auch nach einem fehlgeschlagenen
* Export - "ANZEIGEN" wird nur angeboten, wenn hier tatsächlich eine Datei vorliegt. Wird
* bei jedem neuen Exportversuch zunächst zurückgesetzt, damit niemals eine veraltete Datei
* referenziert werden kann, während ein neuer Export läuft.
*/
val lastExportedPdfFile: File? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
/** Anzahl bereits bearbeiteter Blöcke (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy])
* - ersetzt die frühere "X / Y"-Positionsanzeige, die sich auf den einzelnen, gerade
* angezeigten Block bezog; im Akkordeon-Layout sind ALLE Blöcke gleichzeitig sichtbar
* (nur der Inhalt ist ein-/ausklappbar), daher ist ein Fortschritt sinnvoller als eine
* Position. */
val editedBlockCount: Int get() = documentation?.blocks?.count { it.isEdited } ?: 0
} }
/** /**
@@ -44,6 +90,7 @@ data class DocumentationEditUiState(
class DocumentationEditViewModel( class DocumentationEditViewModel(
private val documentationId: String, private val documentationId: String,
private val repository: DocumentationRepository, private val repository: DocumentationRepository,
private val woundCarerRepository: WoundCarerRepository,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DocumentationEditUiState()) private val _uiState = MutableStateFlow(DocumentationEditUiState())
@@ -127,19 +174,230 @@ class DocumentationEditViewModel(
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) } viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
} }
fun onNextBlock() { /**
* Klappt den Block mit [blockStateId] auf bzw. zu (Änderungswunsch: Akkordeon statt
* Vor-/Zurück-Navigation). Antippen eines bereits aufgeklappten Blocks klappt ihn zu;
* Antippen eines anderen Blocks klappt diesen auf und ersetzt dabei automatisch einen zuvor
* aufgeklappten Block - es ist also IMMER höchstens ein Block gleichzeitig aufgeklappt,
* ganz ohne zusätzliche Schließen-Logik für den vorherigen.
*/
fun toggleBlockExpanded(blockStateId: String) {
_uiState.update { state -> _uiState.update { state ->
val lastIndex = state.blockCount - 1 val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
if (lastIndex < 0) return@update state state.copy(expandedBlockId = newExpandedId)
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
} }
} }
fun onPreviousBlock() { /**
* Importiert die vom Android Photo Picker ausgewählten Bilder (MEDIA-001). Ein leerer
* [uris] (Nutzer hat den Picker ohne Auswahl verlassen) ist kein Fehler und wird
* stillschweigend ignoriert.
*
* Jedes Bild wird EINZELN abgefangen: Schlägt der Import eines Bildes fehl (defektes
* Format, nicht mehr lesbare Uri o. Ä.), werden die übrigen, erfolgreich importierbaren
* Bilder trotzdem übernommen - ein einzelnes fehlerhaftes Bild darf einen Mehrfachimport
* nicht komplett verhindern. Am Ende wird die Dokumentation einmalig neu geladen, damit die
* UI konsistent den tatsächlichen Datenbankstand zeigt.
*/
fun onPhotosPicked(uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch {
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
var failureCount = 0
for (uri in uris) {
try {
repository.addPhoto(documentationId, uri)
} catch (e: Exception) {
failureCount++
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
android.util.Log.w(
"DocumentationEdit",
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
)
}
}
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state -> _uiState.update { state ->
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0)) state.copy(
documentation = refreshed ?: state.documentation,
photoImportInProgress = false,
photoImportError = if (failureCount > 0) {
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
} else {
null
},
)
} }
} }
}
fun dismissPhotoImportError() {
_uiState.update { it.copy(photoImportError = null) }
}
/** Öffnet die Löschbestätigung für ein Foto (Aufgabenstellung: "vor dem endgültigen Löschen
* soll eine Bestätigung erfolgen"). */
fun requestDeletePhoto(photoId: String) {
_uiState.update { it.copy(pendingDeletePhotoId = photoId) }
}
fun dismissDeletePhoto() {
_uiState.update { it.copy(pendingDeletePhotoId = null) }
}
fun confirmDeletePhoto() {
val photoId = _uiState.value.pendingDeletePhotoId ?: return
viewModelScope.launch {
repository.removePhoto(photoId)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
pendingDeletePhotoId = null,
)
}
}
}
/** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */
fun requestAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = true) }
}
fun dismissAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = false) }
}
/**
* Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische
* 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]).
* Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst
* NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s.
* [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern"
* bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte.
*/
fun saveSignature(signerName: String, bitmap: Bitmap) {
viewModelScope.launch {
try {
repository.saveSignature(documentationId, signerName, bitmap)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
showAddSignatureDialog = false,
)
}
} catch (e: IllegalArgumentException) {
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
}
}
}
fun dismissSignatureError() {
_uiState.update { it.copy(signatureError = null) }
}
/** Öffnet die Löschbestätigung für eine Signatur (Aufgabenstellung Abschnitt 17: Signaturen
* sind unveränderlich - "korrigieren" bedeutet immer löschen + neu erfassen). */
fun requestDeleteSignature(signatureId: String) {
_uiState.update { it.copy(pendingDeleteSignatureId = signatureId) }
}
fun dismissDeleteSignature() {
_uiState.update { it.copy(pendingDeleteSignatureId = null) }
}
fun confirmDeleteSignature() {
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
viewModelScope.launch {
repository.removeSignature(signatureId)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
pendingDeleteSignatureId = null,
)
}
}
}
/**
* Löst den PDF-Export an (PDF-001/002). Rein zum manuellen Testen der Export-Funktionalität
* gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit
* nicht Teil dieses Schritts, die erzeugte Datei liegt ausschließlich im internen
* App-Speicher. Keine rohe Exception erreicht die UI: [PdfExportResult] wird hier in eine
* kurze, verständliche Meldung übersetzt.
*/
fun exportPdf() {
viewModelScope.launch {
_uiState.update {
it.copy(pdfExportInProgress = true, pdfExportMessage = null, lastExportedPdfFile = null)
}
val result = repository.exportPdf(documentationId)
_uiState.update {
when (result) {
is PdfExportResult.Success -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF erfolgreich erstellt",
lastExportedPdfFile = result.file,
)
is PdfExportResult.Failure -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}",
lastExportedPdfFile = null,
)
}
}
}
}
fun dismissPdfExportMessage() {
_uiState.update { it.copy(pdfExportMessage = null) }
}
private fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
}
/**
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
* [DocumentationEditUiState]): Bitmaps sind vergleichsweise schwergewichtig und sollen nur
* transient in der jeweiligen Compose-Kachel gehalten werden, nicht dauerhaft im
* ViewModel-Zustand. Die eigentliche Datei-/Decodier-Logik bleibt vollständig in
* PhotoStorage (über das Repository) - Compose ruft ausschließlich diese suspend-Funktion
* auf und führt selbst keinen Datei-/ContentResolver-Zugriff aus.
*/
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName)
/** Für die Vollbildansicht beim Antippen einer Foto-Kachel (Änderungswunsch). */
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) =
repository.loadPhotoFullSize(documentationId, fileName, maxWidthPx, maxHeightPx)
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
repository.loadSignatureBitmap(documentationId, fileName)
/** Für die Vorbelegung des "Signatur hinzufügen"-Dialogs beim Öffnen: der zuletzt
* verwendete Wundversorger app-weit (nicht auf diese Dokumentation beschränkt) - `null`,
* wenn noch nie eine Signatur gespeichert wurde. Weiterhin änderbar (Änderungswunsch). */
suspend fun getDefaultWoundCarer(): WoundCarer? = woundCarerRepository.getMostRecentlyUsed()
/** Namensvorschläge für die Autovervollständigung im Signatur-Dialog. */
suspend fun searchWoundCarers(query: String): List<WoundCarer> = woundCarerRepository.search(query)
/** Lädt die hinterlegte Signatur eines bekannten Wundversorgers (Vorbelegung bzw. nach Tippen
* auf einen Namensvorschlag) - unabhängig von [loadSignatureBitmap], das historische, pro
* Dokumentation gespeicherte Signaturen lädt. */
suspend fun loadWoundCarerSignatureBitmap(woundCarerId: String, fileName: String) =
woundCarerRepository.loadSignatureBitmap(woundCarerId, fileName)
/** /**
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom * Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
@@ -169,6 +427,7 @@ class DocumentationEditViewModel(
DocumentationEditViewModel( DocumentationEditViewModel(
documentationId = route.documentationId, documentationId = route.documentationId,
repository = container.documentationRepository, repository = container.documentationRepository,
woundCarerRepository = container.woundCarerRepository,
) )
} }
} }
@@ -0,0 +1,124 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.Photo
/**
* Vollbild-Ansicht eines einzelnen Fotos in Originalgröße (Änderungswunsch: "Fotos in der App
* durch Antippen in Originalgröße angezeigt werden"). Lädt das Foto über [loadFullSize] separat
* von der kleinen Grid-Vorschau ([DocumentationEditViewModel.loadPhotoThumbnail]) - dieselbe
* Trennung wie bei den Signatur-Vorschauen: die kleine Kachel und die Vollbildansicht sind
* unabhängige Ladevorgänge mit unterschiedlicher Zielauflösung.
*
* "Originalgröße" bedeutet hier: so groß wie der Bildschirm es zulässt, ohne unnötig über die
* tatsächliche Displayauflösung hinaus zu decodieren (s. Kommentar an
* [de.medapp.wunddoku.data.repository.DocumentationRepository.loadPhotoFullSize]) - eine echte
* 1:1-Pixel-Darstellung würde bei modernen Fotoauflösungen ohnehin über die Bildschirmgröße
* hinausgehen und wäre nur per Zoom/Scroll sichtbar, was hier nicht gefordert ist.
*
* Volle Bildschirmbreite/-höhe via [DialogProperties.usePlatformDefaultWidth] = false, da der
* Standard-Dialog-Rahmen von Compose sonst deutlich schmaler als der Bildschirm wäre. Schließen
* über den Schließen-Button ODER Antippen des dunklen Hintergrunds.
*/
@Composable
fun PhotoViewerDialog(
photo: Photo,
loadFullSize: suspend (documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) -> Bitmap?,
onDismiss: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
val configuration = LocalConfiguration.current
val density = LocalDensity.current
LaunchedEffect(photo.id) {
val maxWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
val maxHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() }
val result = runCatching {
loadFullSize(photo.documentationId, photo.fileName, maxWidthPx, maxHeightPx)
}.getOrNull()
bitmap = result
loadFailed = result == null
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(16.dp),
contentScale = ContentScale.Fit,
)
loadFailed -> Box(modifier = Modifier.fillMaxSize()) {
Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
tint = Color.White,
modifier = Modifier.align(Alignment.Center).size(48.dp),
)
Text(
stringResource(R.string.documentation_edit_photo_load_failed),
color = Color.White,
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
)
}
else -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color.White,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_cancel), tint = Color.White)
}
}
}
}
@@ -0,0 +1,235 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.WoundCarer
import kotlinx.coroutines.launch
/**
* Dialog zum Erfassen einer neuen Signatur (MEDIA-003): Name des Wundversorgers + Zeichenfläche
* ([SignaturePad]), wie in der Aufgabenstellung (Abschnitt 7) als bevorzugte UX beschrieben -
* bewusst als Dialog INNERHALB von [de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen],
* keine eigene Navigationsroute.
*
* Änderungswunsch (Wundversorger-Register): Beim Öffnen wird automatisch der ZULETZT verwendete
* Wundversorger samt seiner zuletzt gespeicherten Signatur vorbelegt ([loadDefaultWoundCarer]) -
* bleibt aber änderbar. Während der Eingabe werden nach den ersten Buchstaben passende bekannte
* Namen als antippbare Vorschläge angezeigt ([searchWoundCarers]); ein Tippen darauf übernimmt
* Name UND zuletzt gespeicherte Signatur dieses Wundversorgers ([loadWoundCarerSignatureBitmap]).
* In diesem "Vorschau-Modus" wird die geladene Signatur als fertiges Bild angezeigt (nicht mehr
* frei bearbeitbar) - über "Neu zeichnen" kann jederzeit zu einer frischen, freihändigen
* Zeichnung für denselben Namen gewechselt werden. Wird der Name manuell so verändert, dass er
* nicht mehr zum gerade geladenen Vorschlag passt, wird die Vorschau verworfen (verhindert, dass
* versehentlich die Signatur einer anderen Person unter einem neuen Namen gespeichert wird).
*
* "Speichern" ist erst aktiv, wenn BEIDE Bedingungen erfüllt sind (erste Absicherungsebene gegen
* leere Signaturen bzw. leeren Namen, s. Aufgabenstellung Abschnitt 12 - die zweite, unabhängige
* Ebene sitzt serverseitig in
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature]):
* - der (getrimmte) Name ist nicht leer
* - es liegt entweder eine geladene Vorschau-Signatur ODER eine frisch gezeichnete Signatur vor
*/
@Composable
fun AddSignatureDialog(
onDismiss: () -> Unit,
onSave: (signerName: String, bitmap: Bitmap) -> Unit,
loadDefaultWoundCarer: suspend () -> WoundCarer?,
searchWoundCarers: suspend (query: String) -> List<WoundCarer>,
loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?,
) {
var signerName by remember { mutableStateOf("") }
var strokes by remember { mutableStateOf<List<SignatureStroke>>(emptyList()) }
var canvasSize by remember { mutableStateOf(Size.Zero) }
// != null => Vorschau-Modus: eine bereits bekannte Signatur wird als fertiges Bild gezeigt
// statt einer leeren, frei bearbeitbaren Zeichenfläche.
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Name, zu dem [previewBitmap] gehört - dient ausschließlich dazu, die Vorschau zu verwerfen,
// sobald der Name manuell auf etwas anderes geändert wird (s. Klassenkommentar).
var previewOwnerName by remember { mutableStateOf<String?>(null) }
var suggestions by remember { mutableStateOf<List<WoundCarer>>(emptyList()) }
val scope = rememberCoroutineScope()
// Einmalige Vorbelegung beim Öffnen mit dem zuletzt verwendeten Wundversorger.
LaunchedEffect(Unit) {
val default = loadDefaultWoundCarer() ?: return@LaunchedEffect
signerName = default.name
previewOwnerName = default.name
previewBitmap = loadWoundCarerSignatureBitmap(default.id, default.signatureFileName)
}
// Vorschläge nachladen UND Vorschau verwerfen, sobald der Name vom Vorschau-Eigentümer
// abweicht (z. B. weil der Nutzer den vorbelegten Namen überschreibt).
LaunchedEffect(signerName) {
if (previewOwnerName != null && signerName != previewOwnerName) {
previewBitmap = null
previewOwnerName = null
}
suggestions = if (previewOwnerName == signerName) {
emptyList()
} else {
searchWoundCarers(signerName)
}
}
val canSave = signerName.trim().isNotEmpty() && (previewBitmap != null || strokes.isNotEmpty())
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text(
stringResource(R.string.signature_dialog_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = signerName,
onValueChange = { signerName = it },
label = { Text(stringResource(R.string.signature_dialog_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(suggestions, key = { it.id }) { suggestion ->
AssistChip(
onClick = {
signerName = suggestion.name
previewOwnerName = suggestion.name
suggestions = emptyList()
scope.launch {
previewBitmap = loadWoundCarerSignatureBitmap(
suggestion.id,
suggestion.signatureFileName,
)
}
},
label = { Text(suggestion.name) },
)
}
}
}
Spacer(Modifier.height(12.dp))
val currentPreview = previewBitmap
if (currentPreview != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp)),
) {
Image(
bitmap = currentPreview.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(180.dp).padding(8.dp),
contentScale = ContentScale.Fit,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = {
previewBitmap = null
strokes = emptyList()
}) {
Text(stringResource(R.string.signature_dialog_redraw))
}
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp))
.onSizeChanged { size -> canvasSize = Size(size.width.toFloat(), size.height.toFloat()) },
) {
SignaturePad(
strokes = strokes,
onStrokesChanged = { strokes = it },
modifier = Modifier.fillMaxWidth().height(180.dp),
)
}
Text(
stringResource(R.string.signature_dialog_single_stroke_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { strokes = emptyList() }, enabled = strokes.isNotEmpty()) {
Text(stringResource(R.string.signature_dialog_reset))
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val bitmap = previewBitmap ?: renderSignatureBitmap(strokes, canvasSize)
onSave(signerName.trim(), bitmap)
},
enabled = canSave,
) {
Text(stringResource(R.string.signature_dialog_save))
}
}
}
}
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
/** Logisches Signaturformat, unabhängig von der tatsächlichen Größe der Compose-Zeichenfläche
* auf einem konkreten Gerät (Aufgabenstellung Abschnitt 10). NUR für die persistierte Bitmap
* relevant - die UI selbst darf/soll responsiv sein. */
const val SIGNATURE_LOGICAL_WIDTH = 1000
const val SIGNATURE_LOGICAL_HEIGHT = 400
/** Strichbreite in Bildschirm-dp während des Zeichnens. Die tatsächliche, im PNG persistierte
* Strichbreite ergibt sich aus [SIGNATURE_LOGICAL_STROKE_WIDTH] nach der Normalisierung. */
private const val SIGNATURE_UI_STROKE_WIDTH_PX = 5f
private const val SIGNATURE_LOGICAL_STROKE_WIDTH = 6f
/**
* Eine einzelne, zusammenhängende Zeichenbewegung (ein "Strich") in tatsächlichen
* Canvas-Pixelkoordinaten (nicht normalisiert).
*/
typealias SignatureStroke = List<Offset>
/**
* Compose-basierte Signaturzeichenfläche (MEDIA-003). Unterstützt Finger UND Stylus ohne
* Sonderbehandlung - Compose liefert beide über dieselbe Zeigereingabe-Pipeline
* ([pointerInput]/[detectDragGestures]), es gibt auf Android keinen technischen Unterschied
* zwischen einer Finger- und einer Stylus-Berührung an dieser Stelle.
*
* FACHLICHE EINSCHRÄNKUNG (Änderungswunsch): Eine gültige Signatur besteht aus GENAU EINEM
* durchgehenden Strich - ein erneutes Ansetzen (zweiter Touch-Down, nachdem bereits ein Strich
* gezeichnet wurde) verwirft die komplette bisherige Zeichnung automatisch und beginnt neu mit
* genau diesem neuen Strich. Es kann also beliebig oft neu versucht werden, aber niemals mehrere
* getrennte Striche zu EINER Signatur zusammengesetzt werden. Umgesetzt, indem [onDragEnd] die
* nach oben gemeldete Strichliste immer komplett ERSETZT (`listOf(currentStroke)`) statt an
* [strokes] anzuhängen - dadurch kann zu jedem Zeitpunkt höchstens ein abgeschlossener Strich in
* [strokes] stehen, unabhängig von der Reihenfolge der Ereignisse.
*
* Zustandslos (state hoisting): [strokes] kommt vom Aufrufer, jede Änderung wird über
* [onStrokesChanged] nach oben gemeldet.
*/
@Composable
fun SignaturePad(
strokes: List<SignatureStroke>,
onStrokesChanged: (List<SignatureStroke>) -> Unit,
modifier: Modifier = Modifier,
) {
var currentStroke by remember { mutableStateOf<SignatureStroke>(emptyList()) }
Canvas(
modifier = modifier
.background(Color.White)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset -> currentStroke = listOf(offset) },
onDrag = { change, _ ->
change.consume()
currentStroke = currentStroke + change.position
},
onDragEnd = {
// Ein "Tap" ohne tatsächliche Bewegung (< 2 Punkte) erzeugt bewusst
// keinen sichtbaren Strich - eine bereits vorhandene, gültige Signatur
// bleibt in diesem Fall unangetastet erhalten. Ein ECHTER Strich ersetzt
// dagegen immer die komplette bisherige Zeichnung (s. Klassenkommentar).
if (currentStroke.size > 1) {
onStrokesChanged(listOf(currentStroke))
}
currentStroke = emptyList()
},
onDragCancel = { currentStroke = emptyList() },
)
},
) {
for (stroke in strokes) {
drawSignatureStroke(stroke)
}
if (currentStroke.size > 1) {
drawSignatureStroke(currentStroke)
}
}
}
private fun DrawScope.drawSignatureStroke(points: SignatureStroke) {
if (points.size < 2) return
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (index in 1 until points.size) {
lineTo(points[index].x, points[index].y)
}
}
drawPath(
path = path,
color = Color.Black,
style = Stroke(
width = SIGNATURE_UI_STROKE_WIDTH_PX,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
)
}
/**
* Rendert die erfassten [strokes] (in tatsächlichen Canvas-Pixelkoordinaten der Größe
* [canvasSize]) auf ein neues, weißes Bitmap im logischen Signaturformat
* ([SIGNATURE_LOGICAL_WIDTH] x [SIGNATURE_LOGICAL_HEIGHT]) - unabhängig von der tatsächlichen
* Gerätegröße der Zeichenfläche (Aufgabenstellung Abschnitt 10: Normalisierung). Persistiert
* wird ausschließlich dieses fertige Bitmap, niemals die Stroke-Rohdaten selbst
* (Aufgabenstellung Abschnitt 11).
*/
fun renderSignatureBitmap(strokes: List<SignatureStroke>, canvasSize: Size): Bitmap {
val bitmap = Bitmap.createBitmap(SIGNATURE_LOGICAL_WIDTH, SIGNATURE_LOGICAL_HEIGHT, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
canvas.drawColor(android.graphics.Color.WHITE)
if (canvasSize.width <= 0f || canvasSize.height <= 0f) return bitmap
val scaleX = SIGNATURE_LOGICAL_WIDTH / canvasSize.width
val scaleY = SIGNATURE_LOGICAL_HEIGHT / canvasSize.height
val paint = android.graphics.Paint().apply {
isAntiAlias = true
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.STROKE
strokeWidth = SIGNATURE_LOGICAL_STROKE_WIDTH
strokeCap = android.graphics.Paint.Cap.ROUND
strokeJoin = android.graphics.Paint.Join.ROUND
}
for (stroke in strokes) {
if (stroke.size < 2) continue
val path = android.graphics.Path()
path.moveTo(stroke.first().x * scaleX, stroke.first().y * scaleY)
for (index in 1 until stroke.size) {
path.lineTo(stroke[index].x * scaleX, stroke[index].y * scaleY)
}
canvas.drawPath(path, paint)
}
return bitmap
}
+22 -3
View File
@@ -32,11 +32,30 @@
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string> <string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
<string name="patient_form_save">Speichern</string> <string name="patient_form_save">Speichern</string>
<string name="documentation_edit_position">%1$d / %2$d</string> <string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
<string name="documentation_edit_previous">Zurück</string> <string name="documentation_edit_block_edited">Block bearbeitet</string>
<string name="documentation_edit_next">Weiter</string>
<string name="documentation_edit_free_text_label">Freitext</string> <string name="documentation_edit_free_text_label">Freitext</string>
<string name="documentation_edit_photos_title">Fotos</string>
<string name="documentation_edit_add_photos">Hinzufügen</string>
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
<string name="documentation_edit_photo_load_failed">Foto konnte nicht geladen werden.</string>
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
<string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="documentation_edit_signatures_title">Unterschriften</string>
<string name="documentation_edit_add_signature">Signatur hinzufügen</string>
<string name="documentation_edit_no_signatures">Noch keine Unterschriften vorhanden.</string>
<string name="documentation_edit_delete_signature_confirm_title">Unterschrift löschen?</string>
<string name="documentation_edit_delete_signature_confirm_text">Diese Unterschrift wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="signature_dialog_title">Signatur hinzufügen</string>
<string name="signature_dialog_name_label">Name des Wundversorgers</string>
<string name="signature_dialog_reset">Zurücksetzen</string>
<string name="signature_dialog_save">Speichern</string>
<string name="signature_dialog_redraw">Neu zeichnen</string>
<string name="signature_dialog_single_stroke_hint">Bitte in einem Zug unterschreiben, ohne abzusetzen - bei erneutem Ansetzen wird die Zeichnung zurückgesetzt.</string>
<string name="action_cancel">Abbrechen</string> <string name="action_cancel">Abbrechen</string>
<string name="action_confirm">Bestätigen</string> <string name="action_confirm">Bestätigen</string>
</resources> </resources>