PDF/MEDIA #5
@@ -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,15 +40,18 @@ fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
|
|||||||
createdAt = Instant.ofEpochMilli(it.createdAt),
|
createdAt = Instant.ofEpochMilli(it.createdAt),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
signatures = signatures.map {
|
signatures = signatures
|
||||||
de.medapp.wunddoku.domain.model.Signature(
|
.sortedBy { it.orderIndex }
|
||||||
id = it.id,
|
.map {
|
||||||
documentationId = it.documentationId,
|
de.medapp.wunddoku.domain.model.Signature(
|
||||||
signerName = it.signerName,
|
id = it.id,
|
||||||
fileName = it.fileName,
|
documentationId = it.documentationId,
|
||||||
createdAt = Instant.ofEpochMilli(it.createdAt),
|
signerName = it.signerName,
|
||||||
)
|
fileName = it.fileName,
|
||||||
},
|
orderIndex = it.orderIndex,
|
||||||
|
createdAt = Instant.ofEpochMilli(it.createdAt),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
|
fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -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,
|
||||||
|
|||||||
+94
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
package de.medapp.wunddoku.ui.documentation.edit.signature
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import de.medapp.wunddoku.R
|
||||||
|
import de.medapp.wunddoku.domain.model.WoundCarer
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog zum Erfassen einer neuen Signatur (MEDIA-003): Name des Wundversorgers + Zeichenfläche
|
||||||
|
* ([SignaturePad]), wie in der Aufgabenstellung (Abschnitt 7) als bevorzugte UX beschrieben -
|
||||||
|
* bewusst als Dialog INNERHALB von [de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen],
|
||||||
|
* keine eigene Navigationsroute.
|
||||||
|
*
|
||||||
|
* Änderungswunsch (Wundversorger-Register): Beim Öffnen wird automatisch der ZULETZT verwendete
|
||||||
|
* Wundversorger samt seiner zuletzt gespeicherten Signatur vorbelegt ([loadDefaultWoundCarer]) -
|
||||||
|
* bleibt aber änderbar. Während der Eingabe werden nach den ersten Buchstaben passende bekannte
|
||||||
|
* Namen als antippbare Vorschläge angezeigt ([searchWoundCarers]); ein Tippen darauf übernimmt
|
||||||
|
* Name UND zuletzt gespeicherte Signatur dieses Wundversorgers ([loadWoundCarerSignatureBitmap]).
|
||||||
|
* In diesem "Vorschau-Modus" wird die geladene Signatur als fertiges Bild angezeigt (nicht mehr
|
||||||
|
* frei bearbeitbar) - über "Neu zeichnen" kann jederzeit zu einer frischen, freihändigen
|
||||||
|
* Zeichnung für denselben Namen gewechselt werden. Wird der Name manuell so verändert, dass er
|
||||||
|
* nicht mehr zum gerade geladenen Vorschlag passt, wird die Vorschau verworfen (verhindert, dass
|
||||||
|
* versehentlich die Signatur einer anderen Person unter einem neuen Namen gespeichert wird).
|
||||||
|
*
|
||||||
|
* "Speichern" ist erst aktiv, wenn BEIDE Bedingungen erfüllt sind (erste Absicherungsebene gegen
|
||||||
|
* leere Signaturen bzw. leeren Namen, s. Aufgabenstellung Abschnitt 12 - die zweite, unabhängige
|
||||||
|
* Ebene sitzt serverseitig in
|
||||||
|
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature]):
|
||||||
|
* - der (getrimmte) Name ist nicht leer
|
||||||
|
* - es liegt entweder eine geladene Vorschau-Signatur ODER eine frisch gezeichnete Signatur vor
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AddSignatureDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (signerName: String, bitmap: Bitmap) -> Unit,
|
||||||
|
loadDefaultWoundCarer: suspend () -> WoundCarer?,
|
||||||
|
searchWoundCarers: suspend (query: String) -> List<WoundCarer>,
|
||||||
|
loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?,
|
||||||
|
) {
|
||||||
|
var signerName by remember { mutableStateOf("") }
|
||||||
|
var strokes by remember { mutableStateOf<List<SignatureStroke>>(emptyList()) }
|
||||||
|
var canvasSize by remember { mutableStateOf(Size.Zero) }
|
||||||
|
|
||||||
|
// != null => Vorschau-Modus: eine bereits bekannte Signatur wird als fertiges Bild gezeigt
|
||||||
|
// statt einer leeren, frei bearbeitbaren Zeichenfläche.
|
||||||
|
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
// Name, zu dem [previewBitmap] gehört - dient ausschließlich dazu, die Vorschau zu verwerfen,
|
||||||
|
// sobald der Name manuell auf etwas anderes geändert wird (s. Klassenkommentar).
|
||||||
|
var previewOwnerName by remember { mutableStateOf<String?>(null) }
|
||||||
|
var suggestions by remember { mutableStateOf<List<WoundCarer>>(emptyList()) }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Einmalige Vorbelegung beim Öffnen mit dem zuletzt verwendeten Wundversorger.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val default = loadDefaultWoundCarer() ?: return@LaunchedEffect
|
||||||
|
signerName = default.name
|
||||||
|
previewOwnerName = default.name
|
||||||
|
previewBitmap = loadWoundCarerSignatureBitmap(default.id, default.signatureFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschläge nachladen UND Vorschau verwerfen, sobald der Name vom Vorschau-Eigentümer
|
||||||
|
// abweicht (z. B. weil der Nutzer den vorbelegten Namen überschreibt).
|
||||||
|
LaunchedEffect(signerName) {
|
||||||
|
if (previewOwnerName != null && signerName != previewOwnerName) {
|
||||||
|
previewBitmap = null
|
||||||
|
previewOwnerName = null
|
||||||
|
}
|
||||||
|
suggestions = if (previewOwnerName == signerName) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
searchWoundCarers(signerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val canSave = signerName.trim().isNotEmpty() && (previewBitmap != null || strokes.isNotEmpty())
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.signature_dialog_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = signerName,
|
||||||
|
onValueChange = { signerName = it },
|
||||||
|
label = { Text(stringResource(R.string.signature_dialog_name_label)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(suggestions, key = { it.id }) { suggestion ->
|
||||||
|
AssistChip(
|
||||||
|
onClick = {
|
||||||
|
signerName = suggestion.name
|
||||||
|
previewOwnerName = suggestion.name
|
||||||
|
suggestions = emptyList()
|
||||||
|
scope.launch {
|
||||||
|
previewBitmap = loadWoundCarerSignatureBitmap(
|
||||||
|
suggestion.id,
|
||||||
|
suggestion.signatureFileName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(suggestion.name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
val currentPreview = previewBitmap
|
||||||
|
if (currentPreview != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White, RoundedCornerShape(8.dp)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = currentPreview.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(180.dp).padding(8.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
previewBitmap = null
|
||||||
|
strokes = emptyList()
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.signature_dialog_redraw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White, RoundedCornerShape(8.dp))
|
||||||
|
.onSizeChanged { size -> canvasSize = Size(size.width.toFloat(), size.height.toFloat()) },
|
||||||
|
) {
|
||||||
|
SignaturePad(
|
||||||
|
strokes = strokes,
|
||||||
|
onStrokesChanged = { strokes = it },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(180.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.signature_dialog_single_stroke_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { strokes = emptyList() }, enabled = strokes.isNotEmpty()) {
|
||||||
|
Text(stringResource(R.string.signature_dialog_reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val bitmap = previewBitmap ?: renderSignatureBitmap(strokes, canvasSize)
|
||||||
|
onSave(signerName.trim(), bitmap)
|
||||||
|
},
|
||||||
|
enabled = canSave,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.signature_dialog_save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package de.medapp.wunddoku.ui.documentation.edit.signature
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
|
||||||
|
/** Logisches Signaturformat, unabhängig von der tatsächlichen Größe der Compose-Zeichenfläche
|
||||||
|
* auf einem konkreten Gerät (Aufgabenstellung Abschnitt 10). NUR für die persistierte Bitmap
|
||||||
|
* relevant - die UI selbst darf/soll responsiv sein. */
|
||||||
|
const val SIGNATURE_LOGICAL_WIDTH = 1000
|
||||||
|
const val SIGNATURE_LOGICAL_HEIGHT = 400
|
||||||
|
|
||||||
|
/** Strichbreite in Bildschirm-dp während des Zeichnens. Die tatsächliche, im PNG persistierte
|
||||||
|
* Strichbreite ergibt sich aus [SIGNATURE_LOGICAL_STROKE_WIDTH] nach der Normalisierung. */
|
||||||
|
private const val SIGNATURE_UI_STROKE_WIDTH_PX = 5f
|
||||||
|
private const val SIGNATURE_LOGICAL_STROKE_WIDTH = 6f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine einzelne, zusammenhängende Zeichenbewegung (ein "Strich") in tatsächlichen
|
||||||
|
* Canvas-Pixelkoordinaten (nicht normalisiert).
|
||||||
|
*/
|
||||||
|
typealias SignatureStroke = List<Offset>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose-basierte Signaturzeichenfläche (MEDIA-003). Unterstützt Finger UND Stylus ohne
|
||||||
|
* Sonderbehandlung - Compose liefert beide über dieselbe Zeigereingabe-Pipeline
|
||||||
|
* ([pointerInput]/[detectDragGestures]), es gibt auf Android keinen technischen Unterschied
|
||||||
|
* zwischen einer Finger- und einer Stylus-Berührung an dieser Stelle.
|
||||||
|
*
|
||||||
|
* FACHLICHE EINSCHRÄNKUNG (Änderungswunsch): Eine gültige Signatur besteht aus GENAU EINEM
|
||||||
|
* durchgehenden Strich - ein erneutes Ansetzen (zweiter Touch-Down, nachdem bereits ein Strich
|
||||||
|
* gezeichnet wurde) verwirft die komplette bisherige Zeichnung automatisch und beginnt neu mit
|
||||||
|
* genau diesem neuen Strich. Es kann also beliebig oft neu versucht werden, aber niemals mehrere
|
||||||
|
* getrennte Striche zu EINER Signatur zusammengesetzt werden. Umgesetzt, indem [onDragEnd] die
|
||||||
|
* nach oben gemeldete Strichliste immer komplett ERSETZT (`listOf(currentStroke)`) statt an
|
||||||
|
* [strokes] anzuhängen - dadurch kann zu jedem Zeitpunkt höchstens ein abgeschlossener Strich in
|
||||||
|
* [strokes] stehen, unabhängig von der Reihenfolge der Ereignisse.
|
||||||
|
*
|
||||||
|
* Zustandslos (state hoisting): [strokes] kommt vom Aufrufer, jede Änderung wird über
|
||||||
|
* [onStrokesChanged] nach oben gemeldet.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SignaturePad(
|
||||||
|
strokes: List<SignatureStroke>,
|
||||||
|
onStrokesChanged: (List<SignatureStroke>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var currentStroke by remember { mutableStateOf<SignatureStroke>(emptyList()) }
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.background(Color.White)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { offset -> currentStroke = listOf(offset) },
|
||||||
|
onDrag = { change, _ ->
|
||||||
|
change.consume()
|
||||||
|
currentStroke = currentStroke + change.position
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
// Ein "Tap" ohne tatsächliche Bewegung (< 2 Punkte) erzeugt bewusst
|
||||||
|
// keinen sichtbaren Strich - eine bereits vorhandene, gültige Signatur
|
||||||
|
// bleibt in diesem Fall unangetastet erhalten. Ein ECHTER Strich ersetzt
|
||||||
|
// dagegen immer die komplette bisherige Zeichnung (s. Klassenkommentar).
|
||||||
|
if (currentStroke.size > 1) {
|
||||||
|
onStrokesChanged(listOf(currentStroke))
|
||||||
|
}
|
||||||
|
currentStroke = emptyList()
|
||||||
|
},
|
||||||
|
onDragCancel = { currentStroke = emptyList() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
for (stroke in strokes) {
|
||||||
|
drawSignatureStroke(stroke)
|
||||||
|
}
|
||||||
|
if (currentStroke.size > 1) {
|
||||||
|
drawSignatureStroke(currentStroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawSignatureStroke(points: SignatureStroke) {
|
||||||
|
if (points.size < 2) return
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(points.first().x, points.first().y)
|
||||||
|
for (index in 1 until points.size) {
|
||||||
|
lineTo(points[index].x, points[index].y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawPath(
|
||||||
|
path = path,
|
||||||
|
color = Color.Black,
|
||||||
|
style = Stroke(
|
||||||
|
width = SIGNATURE_UI_STROKE_WIDTH_PX,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
join = StrokeJoin.Round,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert die erfassten [strokes] (in tatsächlichen Canvas-Pixelkoordinaten der Größe
|
||||||
|
* [canvasSize]) auf ein neues, weißes Bitmap im logischen Signaturformat
|
||||||
|
* ([SIGNATURE_LOGICAL_WIDTH] x [SIGNATURE_LOGICAL_HEIGHT]) - unabhängig von der tatsächlichen
|
||||||
|
* Gerätegröße der Zeichenfläche (Aufgabenstellung Abschnitt 10: Normalisierung). Persistiert
|
||||||
|
* wird ausschließlich dieses fertige Bitmap, niemals die Stroke-Rohdaten selbst
|
||||||
|
* (Aufgabenstellung Abschnitt 11).
|
||||||
|
*/
|
||||||
|
fun renderSignatureBitmap(strokes: List<SignatureStroke>, canvasSize: Size): Bitmap {
|
||||||
|
val bitmap = Bitmap.createBitmap(SIGNATURE_LOGICAL_WIDTH, SIGNATURE_LOGICAL_HEIGHT, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = android.graphics.Canvas(bitmap)
|
||||||
|
canvas.drawColor(android.graphics.Color.WHITE)
|
||||||
|
|
||||||
|
if (canvasSize.width <= 0f || canvasSize.height <= 0f) return bitmap
|
||||||
|
|
||||||
|
val scaleX = SIGNATURE_LOGICAL_WIDTH / canvasSize.width
|
||||||
|
val scaleY = SIGNATURE_LOGICAL_HEIGHT / canvasSize.height
|
||||||
|
|
||||||
|
val paint = android.graphics.Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
color = android.graphics.Color.BLACK
|
||||||
|
style = android.graphics.Paint.Style.STROKE
|
||||||
|
strokeWidth = SIGNATURE_LOGICAL_STROKE_WIDTH
|
||||||
|
strokeCap = android.graphics.Paint.Cap.ROUND
|
||||||
|
strokeJoin = android.graphics.Paint.Join.ROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
for (stroke in strokes) {
|
||||||
|
if (stroke.size < 2) continue
|
||||||
|
val path = android.graphics.Path()
|
||||||
|
path.moveTo(stroke.first().x * scaleX, stroke.first().y * scaleY)
|
||||||
|
for (index in 1 until stroke.size) {
|
||||||
|
path.lineTo(stroke[index].x * scaleX, stroke[index].y * scaleY)
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user