PDF/MEDIA #5

Merged
niklas.ortmann merged 4 commits from dev-PDF into dev 2026-09-06 21:20:29 +02:00
25 changed files with 2684 additions and 51 deletions
Showing only changes of commit c52229b7f7 - Show all commits
@@ -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')"
]
}
}
@@ -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,22 @@ 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.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -47,8 +53,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 = 4,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DocumentationStatusConverter::class) @TypeConverters(DocumentationStatusConverter::class)
@@ -60,6 +67,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 +82,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)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -0,0 +1,113 @@
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()
)
}
}
@@ -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),
) )
}, },
@@ -29,7 +29,8 @@ import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.signature.SignatureFileStorage 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
@@ -56,7 +57,8 @@ 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 pdfExporter: DocumentationPdfExporter,
private val db: AppDatabase, private val db: AppDatabase,
) { ) {
@@ -290,37 +292,73 @@ class DocumentationRepository(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { /**
val fileName = signatureFileStorage.save(bitmap) * 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 signatures = signatureDao.getForDocumentation(documentationId)
documentationDao.delete(entity) documentationDao.delete(entity)
// Ein einziger Verzeichnis-Löschvorgang statt Einzeldateien - siehe PhotoStorage- // Je ein einziger Verzeichnis-Löschvorgang statt Einzeldateien für Fotos UND Signaturen
// Kommentar zur dokumentationsbezogenen Ordnerstruktur. Room-Cascade entfernt zwar die // - beide Storage-Implementierungen organisieren ihre Dateien pro documentationId in
// PhotoEntity-Zeilen automatisch, aber keine Dateien aus filesDir - das übernimmt // einem eigenen Unterordner (s. PhotoStorage-/SignatureStorage-Kommentar). Room-Cascade
// ausschließlich diese Methode. // 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 { photoStorage.deleteAll(documentationId) }
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } runCatching { signatureStorage.deleteAll(documentationId) }
} }
/** /**
@@ -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,12 +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.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.signature.InternalSignatureFileStorage import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureFileStorage 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,
@@ -28,13 +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) }
private val pdfExporter: DocumentationPdfExporter by lazy { AndroidPdfDocumentExporter(context, photoStorage) } /** 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(),
@@ -44,7 +59,8 @@ 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, pdfExporter = pdfExporter,
db = database, db = database,
) )
@@ -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,
)
@@ -8,6 +8,7 @@ import android.graphics.Paint
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.text.TextPaint import android.text.TextPaint
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -47,6 +48,7 @@ import java.time.format.FormatStyle
class AndroidPdfDocumentExporter( class AndroidPdfDocumentExporter(
private val context: Context, private val context: Context,
private val photoStorage: PhotoStorage, private val photoStorage: PhotoStorage,
private val signatureStorage: SignatureStorage,
) : DocumentationPdfExporter { ) : DocumentationPdfExporter {
companion object { companion object {
@@ -63,6 +65,25 @@ class AndroidPdfDocumentExporter(
private const val FREE_TEXT_TEXT_SIZE = 10.5f private const val FREE_TEXT_TEXT_SIZE = 10.5f
private const val FREE_TEXT_INDENT = 20f 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
// Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text. // Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text.
private const val CHECKBOX_SIZE = 10f private const val CHECKBOX_SIZE = 10f
private const val CHECKBOX_GAP = 6f private const val CHECKBOX_GAP = 6f
@@ -116,6 +137,10 @@ class AndroidPdfDocumentExporter(
// Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig // Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig
// bleiben und werden deshalb erst im finally-Block unten recycelt. // bleiben und werden deshalb erst im finally-Block unten recycelt.
val retainedPhotoBitmaps = mutableListOf<Bitmap>() 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 { try {
val paints = ExportPaints() val paints = ExportPaints()
val cursor = PdfPageCursor( val cursor = PdfPageCursor(
@@ -132,6 +157,10 @@ class AndroidPdfDocumentExporter(
for (block in model.blocks) { for (block in model.blocks) {
drawBlock(cursor, block, paints) 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()) { for ((index, photo) in model.photos.withIndex()) {
drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps) drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps)
} }
@@ -159,6 +188,7 @@ class AndroidPdfDocumentExporter(
// bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist - // bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist -
// in beiden Fällen werden diese Bitmaps nicht mehr gebraucht. // in beiden Fällen werden diese Bitmaps nicht mehr gebraucht.
retainedPhotoBitmaps.forEach { runCatching { it.recycle() } } retainedPhotoBitmaps.forEach { runCatching { it.recycle() } }
retainedSignatureBitmaps.forEach { runCatching { it.recycle() } }
} }
} }
@@ -197,6 +227,23 @@ class AndroidPdfDocumentExporter(
color = Color.DKGRAY 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
}
// Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit. // Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit.
val checkboxOutline = Paint().apply { val checkboxOutline = Paint().apply {
isAntiAlias = true isAntiAlias = true
@@ -301,6 +348,96 @@ class AndroidPdfDocumentExporter(
} }
} }
/**
* 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). * 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 * Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt
@@ -56,6 +56,13 @@ object DocumentationToPdfExportModelMapper {
.sortedBy { it.orderIndex } .sortedBy { it.orderIndex }
.map { photo -> PdfExportPhoto(fileName = photo.fileName) } .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( return PdfExportModel(
documentationId = documentation.id, documentationId = documentation.id,
fileName = fileName, fileName = fileName,
@@ -64,6 +71,7 @@ object DocumentationToPdfExportModelMapper {
patientDisplayAddress = snapshot.displayAddress, patientDisplayAddress = snapshot.displayAddress,
createdAt = createdAtDate, createdAt = createdAtDate,
blocks = editedBlocks, blocks = editedBlocks,
signatures = signatures,
photos = photos, photos = photos,
) )
} }
@@ -19,6 +19,9 @@ data class PdfExportModel(
val createdAt: LocalDate, val createdAt: LocalDate,
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */ /** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
val blocks: List<PdfExportBlock>, 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). */ /** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */
val photos: List<PdfExportPhoto>, val photos: List<PdfExportPhoto>,
) )
@@ -45,3 +48,15 @@ data class PdfExportItem(
data class PdfExportPhoto( data class PdfExportPhoto(
val fileName: String, 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,
)
@@ -145,6 +145,76 @@ internal class PdfPageCursor(
} }
} }
/**
* Ö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 * Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert
* über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach * über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach
@@ -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()
}
}
@@ -69,9 +69,11 @@ 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.Photo
import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.pdf.AndroidPdfOpener import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.PdfOpenResult import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
/** /**
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
@@ -201,6 +203,7 @@ fun DocumentationEditScreen(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock, block = currentBlock,
photos = documentation.photos, photos = documentation.photos,
signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged, onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged, onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0, canGoBack = uiState.currentBlockIndex > 0,
@@ -217,6 +220,9 @@ fun DocumentationEditScreen(
}, },
onDeletePhotoRequested = viewModel::requestDeletePhoto, onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail, loadThumbnail = viewModel::loadPhotoThumbnail,
onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap,
) )
} }
} }
@@ -241,6 +247,54 @@ fun DocumentationEditScreen(
}, },
) )
} }
// MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene
// Navigationsroute, s. Aufgabenstellung Abschnitt 7).
if (uiState.showAddSignatureDialog) {
AddSignatureDialog(
onDismiss = viewModel::dismissAddSignature,
onSave = viewModel::saveSignature,
loadDefaultWoundCarer = viewModel::getDefaultWoundCarer,
searchWoundCarers = viewModel::searchWoundCarers,
loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap,
)
}
// Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung
// Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher
// deaktiviert, solange Name oder Zeichnung leer sind.
val signatureError = uiState.signatureError
if (signatureError != null) {
AlertDialog(
onDismissRequest = viewModel::dismissSignatureError,
title = { Text(stringResource(R.string.documentation_edit_signatures_title)) },
text = { Text(signatureError) },
confirmButton = {
TextButton(onClick = viewModel::dismissSignatureError) {
Text(stringResource(R.string.action_confirm))
}
},
)
}
val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId
if (pendingDeleteSignatureId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeleteSignature,
title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeleteSignature) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteSignature) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
} }
/** /**
@@ -254,6 +308,7 @@ private fun DocumentationEditContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
block: DocumentationBlock, block: DocumentationBlock,
photos: List<Photo>, photos: List<Photo>,
signatures: List<Signature>,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean, canGoBack: Boolean,
@@ -266,6 +321,9 @@ private fun DocumentationEditContent(
onAddPhotosClick: () -> Unit, onAddPhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) { ) {
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
@@ -302,6 +360,47 @@ private fun DocumentationEditContent(
} }
} }
// 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") { item(key = "photos_header") {
Column { Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@@ -440,6 +539,78 @@ private fun PhotoThumbnail(
} }
} }
/**
* Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und
* Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre
* Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) -
* analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel.
* Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es
* hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen.
*/
@Composable
private fun SignatureRow(
signature: Signature,
loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
) {
var bitmap by remember(signature.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(signature.id) { mutableStateOf(false) }
LaunchedEffect(signature.id) {
val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.width(96.dp)
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
loadFailed -> Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
)
else -> CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
strokeWidth = 2.dp,
)
}
}
Text(
text = signature.signerName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDeleteRequested) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
)
}
}
}
@Composable @Composable
private fun CheckItemRow( private fun CheckItemRow(
item: CheckItem, item: CheckItem,
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.ui.documentation.edit package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.createSavedStateHandle
@@ -8,8 +9,10 @@ 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.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
@@ -35,6 +38,14 @@ data class DocumentationEditUiState(
val photoImportError: String? = null, val photoImportError: String? = null,
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */ /** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
val pendingDeletePhotoId: String? = null, 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). */ /** true, während ein PDF-Export läuft (Ladeindikator, Button währenddessen deaktiviert). */
val pdfExportInProgress: Boolean = false, val pdfExportInProgress: Boolean = false,
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als /** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
@@ -68,6 +79,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())
@@ -237,6 +249,68 @@ class DocumentationEditViewModel(
} }
} }
/** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */
fun requestAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = true) }
}
fun dismissAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = false) }
}
/**
* Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische
* 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]).
* Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst
* NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s.
* [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern"
* bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte.
*/
fun saveSignature(signerName: String, bitmap: Bitmap) {
viewModelScope.launch {
try {
repository.saveSignature(documentationId, signerName, bitmap)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
showAddSignatureDialog = false,
)
}
} catch (e: IllegalArgumentException) {
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
}
}
}
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 * 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 * gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit
@@ -291,6 +365,25 @@ class DocumentationEditViewModel(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName) repository.loadPhotoThumbnail(documentationId, fileName)
/** 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
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei * Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
@@ -319,6 +412,7 @@ class DocumentationEditViewModel(
DocumentationEditViewModel( DocumentationEditViewModel(
documentationId = route.documentationId, documentationId = route.documentationId,
repository = container.documentationRepository, repository = container.documentationRepository,
woundCarerRepository = container.woundCarerRepository,
) )
} }
} }
@@ -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
}
+13
View File
@@ -43,6 +43,19 @@
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string> <string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
<string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string> <string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<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>