Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63bc93ebd2 | |||
| 08091a15a2 | |||
| 4a62d195ac | |||
| 075905c6e0 | |||
| 7948d2c3c6 | |||
| 2ddc1d960a | |||
| 32d23f5d52 | |||
| cda0b4d3fe |
@@ -0,0 +1,629 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 5,
|
||||||
|
"identityHash": "12448571f5ffcf00398fa1d8260c9b2b",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "patients",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "firstName",
|
||||||
|
"columnName": "firstName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastName",
|
||||||
|
"columnName": "lastName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "birthDate",
|
||||||
|
"columnName": "birthDate",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "street",
|
||||||
|
"columnName": "street",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "postalCode",
|
||||||
|
"columnName": "postalCode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "city",
|
||||||
|
"columnName": "city",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone1",
|
||||||
|
"columnName": "phone1",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone2",
|
||||||
|
"columnName": "phone2",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone3",
|
||||||
|
"columnName": "phone3",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_patients_lastName_firstName",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"lastName",
|
||||||
|
"firstName"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_patients_birthDate",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"birthDate"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "documentations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientId",
|
||||||
|
"columnName": "patientId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "continuedFromDocumentationId",
|
||||||
|
"columnName": "continuedFromDocumentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pdfLastGeneratedAt",
|
||||||
|
"columnName": "pdfLastGeneratedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "catalogVersion",
|
||||||
|
"columnName": "catalogVersion",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientFirstNameSnapshot",
|
||||||
|
"columnName": "patientFirstNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientLastNameSnapshot",
|
||||||
|
"columnName": "patientLastNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientBirthDateSnapshot",
|
||||||
|
"columnName": "patientBirthDateSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientStreetSnapshot",
|
||||||
|
"columnName": "patientStreetSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientPostalCodeSnapshot",
|
||||||
|
"columnName": "patientPostalCodeSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientCitySnapshot",
|
||||||
|
"columnName": "patientCitySnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_documentations_patientId_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"patientId",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_continuedFromDocumentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "patients",
|
||||||
|
"onDelete": "RESTRICT",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"patientId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "block_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockDefinitionId",
|
||||||
|
"columnName": "blockDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "titleSnapshot",
|
||||||
|
"columnName": "titleSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockOrder",
|
||||||
|
"columnName": "blockOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId_blockDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"blockDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "check_item_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockStateId",
|
||||||
|
"columnName": "blockStateId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checkItemDefinitionId",
|
||||||
|
"columnName": "checkItemDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "labelSnapshot",
|
||||||
|
"columnName": "labelSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemOrder",
|
||||||
|
"columnName": "itemOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checked",
|
||||||
|
"columnName": "checked",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFreeText",
|
||||||
|
"columnName": "isFreeText",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "freeTextValue",
|
||||||
|
"columnName": "freeTextValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isStaticHint",
|
||||||
|
"columnName": "isStaticHint",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId",
|
||||||
|
"checkItemDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "block_states",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "photos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_photos_documentationId_orderIndex",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"orderIndex"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "signatures",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signerName",
|
||||||
|
"columnName": "signerName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_signatures_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "wound_carers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signatureFileName",
|
||||||
|
"columnName": "signatureFileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12448571f5ffcf00398fa1d8260c9b2b')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
|
|||||||
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
|
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
|
||||||
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
|
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
|
||||||
* additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen.
|
* additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen.
|
||||||
|
*
|
||||||
|
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
|
||||||
|
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
|
||||||
|
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -55,7 +59,7 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
|
|||||||
SignatureEntity::class,
|
SignatureEntity::class,
|
||||||
WoundCarerEntity::class,
|
WoundCarerEntity::class,
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(DocumentationStatusConverter::class)
|
@TypeConverters(DocumentationStatusConverter::class)
|
||||||
@@ -83,7 +87,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
DATABASE_NAME,
|
DATABASE_NAME,
|
||||||
)
|
)
|
||||||
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
|
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
|
||||||
.addMigrations(MIGRATION_2_3, MIGRATION_3_4)
|
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||||
.build().also { instance = it }
|
.build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,3 +111,23 @@ val MIGRATION_3_4 = object : Migration(3, 4) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration von Schema-Version 4 auf 5 (finale fachliche Katalog-Inhalte: neuer Content-Typ
|
||||||
|
* "statischer Hinweistext", z. B. "+ bei Bedarf" bei "Empfohlene Wundversorgung").
|
||||||
|
*
|
||||||
|
* Rein additiv - eine einzelne neue, NOT NULL-Spalte mit Default `false` (0) auf einer
|
||||||
|
* bestehenden Tabelle. Anders als bei der Signaturen-Migration (2->3, dort war ein
|
||||||
|
* NULL->NOT-NULL-Wechsel auf einer bereits befüllten Spalte nötig) ist hier ein einfaches
|
||||||
|
* `ALTER TABLE ... ADD COLUMN` mit Default-Wert ausreichend und von SQLite direkt unterstützt -
|
||||||
|
* kein Tabellenneubau nötig. Bestehende Zeilen erhalten automatisch `isStaticHint = 0`
|
||||||
|
* (entspricht ihrem bisherigen, unveränderten Verhalten als normale Checkbox- bzw.
|
||||||
|
* Freitext-Zeile).
|
||||||
|
*/
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE `check_item_states` ADD COLUMN `isStaticHint` INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ import androidx.room.PrimaryKey
|
|||||||
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
|
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
|
||||||
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
|
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
|
||||||
* Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer.
|
* Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer.
|
||||||
|
* Derselbe Mechanismus wird jetzt auch für vom KATALOG selbst definierte Freitext-Checkboxen
|
||||||
|
* verwendet (z. B. "Wundgrund"/"Erscheinung" oder die vier Empfänger-Freitexte bei "Kopie
|
||||||
|
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
|
||||||
|
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
|
||||||
|
*
|
||||||
|
* [isStaticHint]: NEU. Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B.
|
||||||
|
* "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden
|
||||||
|
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
|
||||||
|
* fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel
|
||||||
|
* im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden
|
||||||
|
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
|
||||||
|
* "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt.
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "check_item_states",
|
tableName = "check_item_states",
|
||||||
@@ -40,4 +52,5 @@ data class CheckItemStateEntity(
|
|||||||
val checked: Boolean,
|
val checked: Boolean,
|
||||||
val isFreeText: Boolean = false,
|
val isFreeText: Boolean = false,
|
||||||
val freeTextValue: String = "",
|
val freeTextValue: String = "",
|
||||||
|
val isStaticHint: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
|
|||||||
order = itemOrder,
|
order = itemOrder,
|
||||||
checked = checked,
|
checked = checked,
|
||||||
isFreeText = isFreeText,
|
isFreeText = isFreeText,
|
||||||
|
isStaticHint = isStaticHint,
|
||||||
freeTextValue = freeTextValue,
|
freeTextValue = freeTextValue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
|||||||
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
||||||
import de.medapp.wunddoku.pdf.PdfExportError
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||||
import de.medapp.wunddoku.signature.SignatureBitmapValidation
|
import de.medapp.wunddoku.signature.SignatureBitmapValidation
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ class DocumentationRepository(
|
|||||||
private val signatureStorage: SignatureStorage,
|
private val signatureStorage: SignatureStorage,
|
||||||
private val woundCarerRepository: WoundCarerRepository,
|
private val woundCarerRepository: WoundCarerRepository,
|
||||||
private val pdfExporter: DocumentationPdfExporter,
|
private val pdfExporter: DocumentationPdfExporter,
|
||||||
|
private val pdfToImagesConverter: PdfToImagesConverter,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -185,6 +189,7 @@ class DocumentationRepository(
|
|||||||
itemOrder = item.itemOrder,
|
itemOrder = item.itemOrder,
|
||||||
checked = item.checked,
|
checked = item.checked,
|
||||||
isFreeText = item.isFreeText,
|
isFreeText = item.isFreeText,
|
||||||
|
isStaticHint = item.isStaticHint,
|
||||||
freeTextValue = item.freeTextValue,
|
freeTextValue = item.freeTextValue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -283,6 +288,16 @@ class DocumentationRepository(
|
|||||||
return pdfExporter.export(model)
|
return pdfExporter.export(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert eine bereits erzeugte PDF-Datei (s. [exportPdf]) seitenweise in JPEG-Dateien
|
||||||
|
* (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können"). Nimmt bewusst die
|
||||||
|
* fertige [pdfFile] entgegen, statt die Dokumentation erneut zu laden/zu mappen - der
|
||||||
|
* Aufrufer hat sie direkt aus einem vorangegangenen [exportPdf]-Aufruf zur Verfügung, ein
|
||||||
|
* erneutes Laden wäre unnötige Doppelarbeit.
|
||||||
|
*/
|
||||||
|
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
|
||||||
|
pdfToImagesConverter.convert(documentationId, pdfFile)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
|
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
|
||||||
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
|
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
|
||||||
@@ -292,6 +307,19 @@ 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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt ein Foto in Originalgröße für die Vollbildansicht beim Antippen einer Foto-Kachel
|
||||||
|
* (Änderungswunsch). Verwendet bewusst [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]
|
||||||
|
* statt eines unskalierten Rohzugriffs: Fotos werden bereits beim Import auf eine sinnvolle
|
||||||
|
* Maximalauflösung begrenzt (s. PhotoStorage-Importlogik), ein zusätzliches Herunterrechnen
|
||||||
|
* auf höchstens die tatsächliche Displaygröße ([maxWidthPx]/[maxHeightPx], vom Aufrufer aus
|
||||||
|
* der Bildschirmauflösung ermittelt) vermeidet unnötig hohen Speicherbedarf, ohne das Bild
|
||||||
|
* sichtbar zu verkleinern - der Nutzer sieht es exakt so groß wie der Bildschirm es ohnehin
|
||||||
|
* darstellen könnte.
|
||||||
|
*/
|
||||||
|
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? =
|
||||||
|
photoStorage.decodeFitted(documentationId, fileName, maxWidthPx, maxHeightPx)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
|
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
|
||||||
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
|
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
|
||||||
@@ -363,9 +391,30 @@ class DocumentationRepository(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
|
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
|
||||||
* Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option
|
* Katalog-Eintrag PLUS eine zusätzliche, automatisch angehängte generische Freitext-Option
|
||||||
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL
|
* (isFreeText = true, Label "Freitext") - siehe DocumentationCatalog.FREE_TEXT_LABEL und
|
||||||
* und BlockDefinition.freeTextDefinitionId.
|
* BlockDefinition.freeTextDefinitionId. Diese generische Option wird WEITERHIN für JEDEN
|
||||||
|
* Block angehängt, unabhängig davon, ob der Block selbst bereits eigene, fachlich benannte
|
||||||
|
* `isFreeText`-Einträge definiert (z. B. "Wundgrund + Erscheinung" oder "Kopie
|
||||||
|
* nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine
|
||||||
|
* zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet.
|
||||||
|
*
|
||||||
|
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText` und
|
||||||
|
* `isStaticHint` steuern direkt das Verhalten der jeweiligen Zeile (normale Checkbox,
|
||||||
|
* eigene benannte Freitext-Checkbox, oder nicht editierbarer Hinweistext) - keine
|
||||||
|
* block-spezifische Sonderlogik nötig, die Katalogdefinition allein entscheidet.
|
||||||
|
*
|
||||||
|
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
|
||||||
|
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
|
||||||
|
* "nur angehakte Elemente"-Regel im PDF-Export automatisch auch für sie, ohne dass diese
|
||||||
|
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
|
||||||
|
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
|
||||||
|
*
|
||||||
|
* Die generische Freitext-Option erscheint als ERSTES Element im Block (itemOrder = 0,
|
||||||
|
* niedriger als jeder Katalog-Eintrag, dessen `order` bei 1 beginnt). Betrifft ausschließlich
|
||||||
|
* NEU erstellte Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen
|
||||||
|
* behalten ihre historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s.
|
||||||
|
* Klassendokumentation zu titleSnapshot/labelSnapshot).
|
||||||
*/
|
*/
|
||||||
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
|
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
|
||||||
for (blockDef in DocumentationCatalog.blocks) {
|
for (blockDef in DocumentationCatalog.blocks) {
|
||||||
@@ -383,29 +432,34 @@ class DocumentationRepository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val normalItems = blockDef.items.map { itemDef ->
|
val catalogItems = blockDef.items.map { itemDef ->
|
||||||
CheckItemStateEntity(
|
CheckItemStateEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
blockStateId = blockStateId,
|
blockStateId = blockStateId,
|
||||||
checkItemDefinitionId = itemDef.id,
|
checkItemDefinitionId = itemDef.id,
|
||||||
labelSnapshot = itemDef.label,
|
labelSnapshot = itemDef.label,
|
||||||
itemOrder = itemDef.order,
|
itemOrder = itemDef.order,
|
||||||
checked = false,
|
// Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
|
||||||
isFreeText = false,
|
// anderen Einträge (Checkbox oder eigene Freitext-Checkbox) starten
|
||||||
|
// unangehakt/leer, wie bisher.
|
||||||
|
checked = itemDef.isStaticHint,
|
||||||
|
isFreeText = itemDef.isFreeText,
|
||||||
|
isStaticHint = itemDef.isStaticHint,
|
||||||
freeTextValue = "",
|
freeTextValue = "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val freeTextItem = CheckItemStateEntity(
|
val genericFreeTextItem = CheckItemStateEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
blockStateId = blockStateId,
|
blockStateId = blockStateId,
|
||||||
checkItemDefinitionId = blockDef.freeTextDefinitionId,
|
checkItemDefinitionId = blockDef.freeTextDefinitionId,
|
||||||
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
|
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
|
||||||
itemOrder = blockDef.items.size + 1,
|
itemOrder = 0,
|
||||||
checked = false,
|
checked = false,
|
||||||
isFreeText = true,
|
isFreeText = true,
|
||||||
|
isStaticHint = false,
|
||||||
freeTextValue = "",
|
freeTextValue = "",
|
||||||
)
|
)
|
||||||
checkItemStateDao.insertAll(normalItems + freeTextItem)
|
checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import de.medapp.wunddoku.data.repository.WoundCarerRepository
|
|||||||
import de.medapp.wunddoku.media.InternalPhotoStorage
|
import de.medapp.wunddoku.media.InternalPhotoStorage
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
||||||
|
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
|
||||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||||
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ class AppContainer(context: Context) {
|
|||||||
private val pdfExporter: DocumentationPdfExporter by lazy {
|
private val pdfExporter: DocumentationPdfExporter by lazy {
|
||||||
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
|
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
|
||||||
}
|
}
|
||||||
|
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
|
||||||
|
|
||||||
val patientRepository: PatientRepository by lazy {
|
val patientRepository: PatientRepository by lazy {
|
||||||
PatientRepository(patientDao = database.patientDao())
|
PatientRepository(patientDao = database.patientDao())
|
||||||
@@ -62,6 +65,7 @@ class AppContainer(context: Context) {
|
|||||||
signatureStorage = signatureStorage,
|
signatureStorage = signatureStorage,
|
||||||
woundCarerRepository = woundCarerRepository,
|
woundCarerRepository = woundCarerRepository,
|
||||||
pdfExporter = pdfExporter,
|
pdfExporter = pdfExporter,
|
||||||
|
pdfToImagesConverter = pdfToImagesConverter,
|
||||||
db = database,
|
db = database,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,30 @@ package de.medapp.wunddoku.domain.catalog
|
|||||||
/**
|
/**
|
||||||
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
|
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
|
||||||
*
|
*
|
||||||
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter
|
* Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
|
||||||
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte").
|
* handschriftlich vorgegebene 22-Punkte-Reihenfolge, ergänzt/verfeinert nach Abgleich mit dem
|
||||||
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser
|
* ursprünglichen Papierformular ("Wunddokumentation" von Harald Klock) - dabei wurden:
|
||||||
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden.
|
*
|
||||||
|
* - mehrere Abschnitte, die im Chat/der handschriftlichen Liste nur als Freitext geführt waren,
|
||||||
|
* auf dem Papierbogen aber eine differenzierte Checkbox-Struktur zeigen, entsprechend
|
||||||
|
* erweitert (Wundgrund, Belag, Wundrand, Exsudat, Größe, Fistel/Tasche, Lokalisation,
|
||||||
|
* Empfohlene Wundversorgung),
|
||||||
|
* - zwei auf dem Papierbogen vorhandene, aber in der handschriftlichen Liste fehlende
|
||||||
|
* Abschnitte wieder aufgenommen ("Weitere Überlegung" - vermutlich versehentlich in der
|
||||||
|
* handschriftlichen Liste ausgelassen; "Nächster Termin" - auf dem Bogen als eigenes Feld
|
||||||
|
* vorhanden),
|
||||||
|
* - "Verlauf" bewusst NICHT um die alten Papier-Checkboxen erweitert (explizit vom Kunden
|
||||||
|
* bestätigt: bleibt reiner Freitext, die alte Checkbox-Auswahl wurde bewusst verworfen),
|
||||||
|
* - "Wundumgebung" und "Auswirkungen auf das alltägliche Leben" um die Papier-Checkbox-Listen
|
||||||
|
* ergänzt, jeweils MIT vorangestelltem freiem Textfeld (die ohnehin an jedem Block
|
||||||
|
* automatisch vorhandene generische Freitext-Option deckt das ab, s. [FREE_TEXT_LABEL]),
|
||||||
|
* - "Wundart" um die Papier-Checkbox "nicht bekannt/diagnostiziert" ergänzt (zusätzlich zum
|
||||||
|
* Freitext - NICHT Teil der früher verworfenen Wundarten-Liste, sondern ein eigenständiges
|
||||||
|
* Status-Flag).
|
||||||
|
*
|
||||||
|
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
|
||||||
|
* Inhalte (Wundart-Typenliste, Verlauf-Checkboxen, "Schmerzempfinden 1-10") sind weiterhin
|
||||||
|
* bewusst NICHT enthalten.
|
||||||
*
|
*
|
||||||
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
|
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
|
||||||
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
|
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
|
||||||
@@ -16,51 +36,370 @@ package de.medapp.wunddoku.domain.catalog
|
|||||||
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
|
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
|
||||||
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
|
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
|
||||||
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
|
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
|
||||||
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots).
|
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). Auf 3 erhöht mit dem
|
||||||
|
* Abgleich gegen das ursprüngliche Papierformular.
|
||||||
*/
|
*/
|
||||||
object DocumentationCatalog {
|
object DocumentationCatalog {
|
||||||
|
|
||||||
const val CATALOG_VERSION: Int = 1
|
const val CATALOG_VERSION: Int = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
|
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
|
||||||
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral
|
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral
|
||||||
* hier definiert, damit Repository (Erstellung/Fortsetzen) und ggf. der spätere PDF-Export
|
* hier definiert, damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe
|
||||||
* dieselbe Bezeichnung verwenden.
|
* Bezeichnung verwenden. Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die
|
||||||
|
* fachlich "nur Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
|
||||||
*/
|
*/
|
||||||
const val FREE_TEXT_LABEL: String = "Freitext"
|
const val FREE_TEXT_LABEL: String = "Freitext"
|
||||||
|
|
||||||
val blocks: List<BlockDefinition> = listOf(
|
val blocks: List<BlockDefinition> = listOf(
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "block_01",
|
id = "wundart",
|
||||||
title = "Block 1",
|
title = "Wundart",
|
||||||
order = 1,
|
order = 1,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1),
|
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "nicht bekannt/diagnostiziert", order = 1),
|
||||||
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "block_02",
|
id = "lokalisation",
|
||||||
title = "Block 2",
|
title = "Lokalisation",
|
||||||
order = 2,
|
order = 2,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1),
|
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
|
||||||
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
|
|
||||||
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
id = "block_03",
|
id = "groesse",
|
||||||
title = "Block 3",
|
title = "Größe",
|
||||||
order = 3,
|
order = 3,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1),
|
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "multiple Wunden mit max. (cm)", order = 1, isFreeText = true),
|
||||||
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2),
|
CheckItemDefinition(id = "groesse_wunde", label = "Wunde", order = 2),
|
||||||
|
CheckItemDefinition(id = "groesse_areal", label = "Areal", order = 3),
|
||||||
|
CheckItemDefinition(id = "groesse_masse", label = "Maße (Länge x Breite, ca. cm)", order = 4, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "groesse_vertikale_tiefe", label = "Vertikale Tiefe (ca. cm)", order = 5, isFreeText = true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen
|
BlockDefinition(
|
||||||
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch.
|
id = "verlauf",
|
||||||
|
title = "Verlauf",
|
||||||
|
order = 4,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "exsudat",
|
||||||
|
title = "Exsudat",
|
||||||
|
order = 5,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "exsudat_kein", label = "Kein", order = 1),
|
||||||
|
CheckItemDefinition(id = "exsudat_wenig", label = "Wenig", order = 2),
|
||||||
|
CheckItemDefinition(id = "exsudat_maessig", label = "Mäßig", order = 3),
|
||||||
|
CheckItemDefinition(id = "exsudat_viel", label = "Viel", order = 4),
|
||||||
|
CheckItemDefinition(id = "exsudat_uebermaessig", label = "Übermäßig", order = 5),
|
||||||
|
CheckItemDefinition(id = "exsudat_waessrig", label = "Wässrig", order = 6),
|
||||||
|
CheckItemDefinition(id = "exsudat_zaehfluessig", label = "Zähflüssig", order = 7),
|
||||||
|
CheckItemDefinition(id = "exsudat_eitrig", label = "Eitrig", order = 8),
|
||||||
|
CheckItemDefinition(id = "exsudat_blutig", label = "Blutig", order = 9),
|
||||||
|
CheckItemDefinition(id = "exsudat_klar", label = "Klar", order = 10),
|
||||||
|
CheckItemDefinition(id = "exsudat_gelblich", label = "Gelblich", order = 11),
|
||||||
|
CheckItemDefinition(id = "exsudat_gruenlich", label = "Grünlich", order = 12),
|
||||||
|
CheckItemDefinition(id = "exsudat_braeunlich", label = "Bräunlich", order = 13),
|
||||||
|
CheckItemDefinition(id = "exsudat_roetlich", label = "Rötlich", order = 14),
|
||||||
|
CheckItemDefinition(id = "exsudat_weisslich", label = "Weißlich", order = 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "wundgrund_erscheinung",
|
||||||
|
title = "Wundgrund + Erscheinung",
|
||||||
|
order = 6,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 1),
|
||||||
|
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 2),
|
||||||
|
CheckItemDefinition(id = "wundgrund_knospig", label = "Knospig", order = 3),
|
||||||
|
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 4),
|
||||||
|
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 5),
|
||||||
|
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 6),
|
||||||
|
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 7),
|
||||||
|
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 8),
|
||||||
|
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 9),
|
||||||
|
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 10),
|
||||||
|
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 11),
|
||||||
|
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 12),
|
||||||
|
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 13),
|
||||||
|
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 14),
|
||||||
|
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 15, isFreeText = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "belag",
|
||||||
|
title = "Belag",
|
||||||
|
order = 7,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "belag_nein", label = "Nein", order = 1),
|
||||||
|
CheckItemDefinition(id = "belag_ja", label = "Ja", order = 2),
|
||||||
|
CheckItemDefinition(id = "belag_fibrin", label = "Fibrin", order = 3),
|
||||||
|
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
|
||||||
|
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
|
||||||
|
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
|
||||||
|
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 7),
|
||||||
|
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 8),
|
||||||
|
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 9),
|
||||||
|
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 10),
|
||||||
|
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 11),
|
||||||
|
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 12),
|
||||||
|
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "wundrand",
|
||||||
|
title = "Wundrand",
|
||||||
|
order = 8,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "wundrand_unauffaellig", label = "Unauffällig", order = 1),
|
||||||
|
CheckItemDefinition(id = "wundrand_partiell_unterschiedlich", label = "Partiell unterschiedlich", order = 2),
|
||||||
|
CheckItemDefinition(id = "wundrand_rosarot", label = "Rosarot", order = 3),
|
||||||
|
CheckItemDefinition(id = "wundrand_rot", label = "Rot", order = 4),
|
||||||
|
CheckItemDefinition(id = "wundrand_blaeulich", label = "Bläulich", order = 5),
|
||||||
|
CheckItemDefinition(id = "wundrand_blass", label = "Blass", order = 6),
|
||||||
|
CheckItemDefinition(id = "wundrand_blutig", label = "Blutig", order = 7),
|
||||||
|
CheckItemDefinition(id = "wundrand_mit_kruste_belegt", label = "Mit Kruste belegt", order = 8),
|
||||||
|
CheckItemDefinition(id = "wundrand_mazeriert", label = "Mazeriert", order = 9),
|
||||||
|
CheckItemDefinition(id = "wundrand_unterminiert", label = "Unterminiert", order = 10),
|
||||||
|
CheckItemDefinition(id = "wundrand_trocken", label = "Trocken", order = 11),
|
||||||
|
CheckItemDefinition(id = "wundrand_glatt", label = "Glatt", order = 12),
|
||||||
|
CheckItemDefinition(id = "wundrand_flach_einlaufend", label = "Flach einlaufend", order = 13),
|
||||||
|
CheckItemDefinition(id = "wundrand_wallartig", label = "Wallartig", order = 14),
|
||||||
|
CheckItemDefinition(id = "wundrand_wulstig", label = "Wulstig", order = 15),
|
||||||
|
CheckItemDefinition(id = "wundrand_zerklueftet", label = "Zerklüftet", order = 16),
|
||||||
|
CheckItemDefinition(id = "wundrand_ueberhaengende_strukturen_lefzen", label = "Überhängende Strukturen/Lefzen", order = 17),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "wundumgebung",
|
||||||
|
title = "Wundumgebung",
|
||||||
|
order = 9,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "wundumgebung_unauffaellig", label = "Unauffällig", order = 1),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_oedem", label = "Ödem", order = 2),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_ekzem", label = "Ekzem", order = 3),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_schuppig", label = "Schuppig", order = 4),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_narbengewebe", label = "Narbengewebe", order = 5),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_hornhaut_verhaertet", label = "Hornhaut/verhärtet", order = 6),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_geroetet", label = "Gerötet", order = 7),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 11),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 12),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 13),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "fistel_tasche",
|
||||||
|
title = "Fistel / Tasche",
|
||||||
|
order = 10,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
|
||||||
|
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
|
||||||
|
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
|
||||||
|
CheckItemDefinition(id = "fistel_tasche_von_bis_uhr", label = "Von ... bis ... Uhr", order = 4, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "fistel_tasche_tiefe", label = "Tiefe (ca. cm)", order = 5, isFreeText = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "entzuendungszeichen",
|
||||||
|
title = "Entzündungszeichen",
|
||||||
|
order = 11,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "entzuendungszeichen_nein", label = "Nein", order = 1),
|
||||||
|
CheckItemDefinition(id = "entzuendungszeichen_temperatur_erhoeht", label = "Temperatur erhöht", order = 2),
|
||||||
|
CheckItemDefinition(id = "entzuendungszeichen_schwellung", label = "Schwellung", order = 3),
|
||||||
|
CheckItemDefinition(id = "entzuendungszeichen_roetung", label = "Rötung", order = 4),
|
||||||
|
CheckItemDefinition(id = "entzuendungszeichen_funktionsstoerung", label = "Funktionsstörung", order = 5),
|
||||||
|
CheckItemDefinition(id = "entzuendungszeichen_schmerz", label = "Schmerz", order = 6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "geruch",
|
||||||
|
title = "Geruch",
|
||||||
|
order = 12,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "geruch_ja", label = "Ja", order = 1),
|
||||||
|
CheckItemDefinition(id = "geruch_nein", label = "Nein", order = 2),
|
||||||
|
CheckItemDefinition(id = "geruch_gering", label = "Gering", order = 3),
|
||||||
|
CheckItemDefinition(id = "geruch_deutlich_wahrnehmbar", label = "Deutlich wahrnehmbar", order = 4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "schmerz",
|
||||||
|
title = "Schmerz",
|
||||||
|
order = 13,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "schmerz_ja", label = "Ja", order = 1),
|
||||||
|
CheckItemDefinition(id = "schmerz_nein", label = "Nein", order = 2),
|
||||||
|
CheckItemDefinition(id = "schmerz_immer", label = "Immer", order = 3),
|
||||||
|
CheckItemDefinition(id = "schmerz_zeitweise", label = "Zeitweise", order = 4),
|
||||||
|
CheckItemDefinition(id = "schmerz_bei_verbandwechsel", label = "Bei Verbandwechsel", order = 5),
|
||||||
|
CheckItemDefinition(id = "schmerz_vorwiegend_nachts", label = "Vorwiegend nachts", order = 6),
|
||||||
|
CheckItemDefinition(id = "schmerz_wunde", label = "Wunde", order = 7),
|
||||||
|
CheckItemDefinition(id = "schmerz_umgebung", label = "Umgebung", order = 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "juckreiz",
|
||||||
|
title = "Juckreiz",
|
||||||
|
order = 14,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "juckreiz_ja", label = "Ja", order = 1),
|
||||||
|
CheckItemDefinition(id = "juckreiz_nein", label = "Nein", order = 2),
|
||||||
|
CheckItemDefinition(id = "juckreiz_wunde", label = "Wunde", order = 3),
|
||||||
|
CheckItemDefinition(id = "juckreiz_umgebung", label = "Umgebung", order = 4),
|
||||||
|
CheckItemDefinition(id = "juckreiz_immer_oft", label = "Immer / oft", order = 5),
|
||||||
|
CheckItemDefinition(id = "juckreiz_zeitweise", label = "Zeitweise", order = 6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "vitalwerte",
|
||||||
|
title = "Vitalwerte",
|
||||||
|
order = 15,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "auswirkungen_alltag",
|
||||||
|
title = "Auswirkungen auf das alltägliche Leben",
|
||||||
|
order = 16,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_ja", label = "Ja", order = 1),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 2),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_nicht_ermittelbar", label = "Nicht ermittelbar", order = 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "bekannte_allergie",
|
||||||
|
title = "Bekannte Allergie",
|
||||||
|
order = 17,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "beratung_erfolgte",
|
||||||
|
title = "Beratung erfolgte",
|
||||||
|
order = 18,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
|
||||||
|
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
|
||||||
|
CheckItemDefinition(id = "beratung_ernaehrung", label = "Ernährung", order = 3),
|
||||||
|
CheckItemDefinition(id = "beratung_lagerung_druckentlastung", label = "Lagerung / Druckentlastung", order = 4),
|
||||||
|
CheckItemDefinition(id = "beratung_kleidung_schuhe", label = "Kleidung / Schuhe", order = 5),
|
||||||
|
CheckItemDefinition(id = "beratung_hautpflege", label = "Hautpflege", order = 6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "weitere_ueberlegung",
|
||||||
|
title = "Weitere Überlegung",
|
||||||
|
order = 19,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_wundabstrich", label = "Wundabstrich", order = 3),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_angepasste_schmerztherapie", label = "Angepasste Schmerztherapie", order = 4),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_anpassung_wundversorgung", label = "Anpassung der Wundversorgung", order = 5),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_facharztliches_konsil", label = "Fachärztliches Konsil", order = 6),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_ernaehrungsberatung", label = "Ernährungsberatung", order = 7),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_pflegeberatung", label = "Pflegeberatung", order = 8),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_hilfsmittel", label = "Hilfsmittel", order = 9),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_bewegung_lagerungsplan", label = "Bewegung / Lagerungsplan", order = 10),
|
||||||
|
CheckItemDefinition(id = "weitere_ueberlegung_verbandwechsel_pflegedienst", label = "Verbandwechsel durch Pflegefachkräfte / Pflegedienst", order = 11),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "weitere_ueberlegung_aerztliche_abklaerung_dringend",
|
||||||
|
label = "Ärztliche Abklärung zeitnah/dringend erforderlich / erbeten, Gefahr von bleibenden körperlichen Schäden / lebensbedrohlichen Prozessen",
|
||||||
|
order = 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "sonstiges",
|
||||||
|
title = "Sonstiges",
|
||||||
|
order = 20,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "empfohlene_wundversorgung",
|
||||||
|
title = "Empfohlene Wundversorgung",
|
||||||
|
order = 21,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_getraenkte_kompresse", label = "Getränkte Kompresse für ca. ... Min. auflegen", order = 6, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_secura", label = "Secura", order = 15),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_cavilon", label = "Cavilon", order = 16),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 17, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 18),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 19),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 20, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 21),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 22),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 23),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 24),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 25),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband", label = "Kompressionsverband", order = 26),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 27, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 28, isStaticHint = true),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
||||||
|
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
||||||
|
order = 29,
|
||||||
|
isStaticHint = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "ziel",
|
||||||
|
title = "Ziel",
|
||||||
|
order = 22,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
|
||||||
|
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
|
||||||
|
CheckItemDefinition(id = "ziel_abpolsterung", label = "Abpolsterung", order = 3),
|
||||||
|
CheckItemDefinition(id = "ziel_schmerzreduktion", label = "Schmerzreduktion", order = 4),
|
||||||
|
CheckItemDefinition(id = "ziel_angepasstes_exsudatmanagement", label = "Angepasstes Exsudatmanagement", order = 5),
|
||||||
|
CheckItemDefinition(id = "ziel_stabile_wundverhaeltnisse", label = "Stabile Wundverhältnisse", order = 6),
|
||||||
|
CheckItemDefinition(id = "ziel_abheilung", label = "Abheilung", order = 7),
|
||||||
|
CheckItemDefinition(id = "ziel_geruchsreduzierung", label = "Geruchsreduzierung", order = 8),
|
||||||
|
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
|
||||||
|
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
|
||||||
|
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "kopie_nachrichtlich_an",
|
||||||
|
title = "Kopie nachrichtlich an",
|
||||||
|
order = 23,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "kopie_nachrichtlich_an_pflege", label = "Pflege", order = 2, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "kopie_nachrichtlich_an_patient", label = "Patient", order = 3, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "kopie_nachrichtlich_an_sonstige", label = "Sonstige", order = 4, isFreeText = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "naechster_termin",
|
||||||
|
title = "Nächster Termin",
|
||||||
|
order = 24,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun findBlock(blockDefinitionId: String): BlockDefinition? =
|
fun findBlock(blockDefinitionId: String): BlockDefinition? =
|
||||||
@@ -74,16 +413,39 @@ data class BlockDefinition(
|
|||||||
val items: List<CheckItemDefinition>,
|
val items: List<CheckItemDefinition>,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte Freitext-Option
|
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte, generische
|
||||||
* dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein Bestandteil von [items],
|
* Freitext-Option dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein
|
||||||
* da die Freitext-Option kein fachlicher Katalog-Eintrag ist, sondern strukturell zu jedem
|
* Bestandteil von [items], da diese generische Freitext-Option kein fachlicher
|
||||||
* Block gehört.
|
* Katalog-Eintrag ist, sondern strukturell zu JEDEM Block gehört - unabhängig davon, ob
|
||||||
|
* der Block selbst bereits eigene `isFreeText`-Einträge in [items] definiert.
|
||||||
*/
|
*/
|
||||||
val freeTextDefinitionId: String get() = "${id}_freetext"
|
val freeTextDefinitionId: String get() = "${id}_freetext"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
|
||||||
|
*
|
||||||
|
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false).
|
||||||
|
*
|
||||||
|
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
|
||||||
|
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
|
||||||
|
* nachrichtlich an") - technisch identisch zur automatisch an JEDEN Block angehängten
|
||||||
|
* generischen Freitext-Option (s. DocumentationRepository.insertBlocksFromCatalog), nur mit
|
||||||
|
* eigenem Label statt "Freitext". Ein Block kann beliebig viele solcher Einträge definieren.
|
||||||
|
*
|
||||||
|
* [isStaticHint] = true: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" oder eine
|
||||||
|
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
|
||||||
|
* Text selbst, keine Checkbox-Beschriftung.
|
||||||
|
*
|
||||||
|
* [isFreeText] und [isStaticHint] schließen sich fachlich gegenseitig aus (ein Eintrag ist
|
||||||
|
* entweder normale Checkbox, Freitext-Checkbox oder Hinweistext) - es gibt aber bewusst keine
|
||||||
|
* Laufzeitprüfung dafür, um die Katalogdefinition einfach zu halten; beide gleichzeitig auf
|
||||||
|
* `true` zu setzen ergibt schlicht keinen sinnvollen Katalogeintrag.
|
||||||
|
*/
|
||||||
data class CheckItemDefinition(
|
data class CheckItemDefinition(
|
||||||
val id: String,
|
val id: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val order: Int,
|
val order: Int,
|
||||||
|
val isFreeText: Boolean = false,
|
||||||
|
val isStaticHint: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ package de.medapp.wunddoku.domain.model
|
|||||||
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
|
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
|
||||||
* dargestellt werden.
|
* dargestellt werden.
|
||||||
*
|
*
|
||||||
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am
|
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT als separates Feld am
|
||||||
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block
|
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt. Jeder Block
|
||||||
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`,
|
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`; zusätzlich kann der Katalog
|
||||||
* ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren
|
* selbst beliebig viele weitere `isFreeText = true`-Optionen mit eigenem, fachlichem Label
|
||||||
* PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu
|
* definieren (z. B. "Wundgrund", "Erscheinung", oder die vier Empfänger bei "Kopie
|
||||||
* unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen.
|
* nachrichtlich an") - technisch identisch, nur mit eigenem Label statt des generischen
|
||||||
|
* "Freitext". Ist [isFreeText] `false`, ist [freeTextValue] stets leer und ohne Bedeutung.
|
||||||
|
*
|
||||||
|
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
|
||||||
|
* Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity-
|
||||||
|
* Kommentar), zählt aber NICHT als Bearbeitung (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]).
|
||||||
*/
|
*/
|
||||||
data class CheckItem(
|
data class CheckItem(
|
||||||
val checkItemStateId: String,
|
val checkItemStateId: String,
|
||||||
@@ -24,4 +29,5 @@ data class CheckItem(
|
|||||||
val checked: Boolean,
|
val checked: Boolean,
|
||||||
val isFreeText: Boolean = false,
|
val isFreeText: Boolean = false,
|
||||||
val freeTextValue: String = "",
|
val freeTextValue: String = "",
|
||||||
|
val isStaticHint: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ object BlockEditedPolicy {
|
|||||||
* nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt
|
* nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt
|
||||||
* ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon
|
* ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon
|
||||||
* Text eingegeben wurde.
|
* Text eingegeben wurde.
|
||||||
|
*
|
||||||
|
* EINZIGE Ausnahme: Hinweistext-Zeilen (CheckItem.isStaticHint = true) werden hierbei
|
||||||
|
* IGNORIERT, obwohl ihr `checked` technisch immer `true` ist (s. Entity-Kommentar zu
|
||||||
|
* isStaticHint) - ein reiner, nicht editierbarer Hinweistext ist keine Nutzereingabe und
|
||||||
|
* darf einen Block nicht allein dadurch als "bearbeitet" erscheinen lassen (z. B.
|
||||||
|
* "Empfohlene Wundversorgung" enthält zwei feste Hinweiszeilen, soll aber nur dann als
|
||||||
|
* bearbeitet gelten, wenn tatsächlich eine Empfehlung eingetragen wurde).
|
||||||
*/
|
*/
|
||||||
fun isEdited(block: DocumentationBlock): Boolean {
|
fun isEdited(block: DocumentationBlock): Boolean {
|
||||||
return block.items.any { it.checked }
|
return block.items.any { it.checked && !it.isStaticHint }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package de.medapp.wunddoku.pdf
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.pdf.PdfDocument
|
import android.graphics.pdf.PdfDocument
|
||||||
@@ -84,10 +83,9 @@ class AndroidPdfDocumentExporter(
|
|||||||
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
|
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
|
||||||
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
|
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
|
||||||
|
|
||||||
// Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text.
|
// Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
|
||||||
private const val CHECKBOX_SIZE = 10f
|
// der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
|
||||||
private const val CHECKBOX_GAP = 6f
|
private const val ITEM_INDENT = 10f
|
||||||
private const val CHECKBOX_INDENT = CHECKBOX_SIZE + CHECKBOX_GAP
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
|
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
|
||||||
@@ -244,21 +242,6 @@ class AndroidPdfDocumentExporter(
|
|||||||
color = Color.DKGRAY
|
color = Color.DKGRAY
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit.
|
|
||||||
val checkboxOutline = Paint().apply {
|
|
||||||
isAntiAlias = true
|
|
||||||
style = Paint.Style.STROKE
|
|
||||||
strokeWidth = 1.2f
|
|
||||||
color = Color.BLACK
|
|
||||||
}
|
|
||||||
val checkboxMark = Paint().apply {
|
|
||||||
isAntiAlias = true
|
|
||||||
style = Paint.Style.STROKE
|
|
||||||
strokeWidth = 1.5f
|
|
||||||
strokeCap = Paint.Cap.ROUND
|
|
||||||
color = Color.BLACK
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
|
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
|
||||||
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
|
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
|
||||||
val photo = Paint().apply {
|
val photo = Paint().apply {
|
||||||
@@ -294,24 +277,23 @@ class AndroidPdfDocumentExporter(
|
|||||||
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
|
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Änderungswunsch: KEIN Checkbox-Symbol mehr im PDF. Da [PdfExportBlock.items] bereits nur
|
||||||
|
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
|
||||||
|
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
|
||||||
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
|
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
|
||||||
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
|
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
|
||||||
val labelText = if (item.isFreeText) "${item.label}:" else item.label
|
val labelText = if (item.isFreeText) "${item.label}:" else item.label
|
||||||
val checked = item.checked
|
|
||||||
|
|
||||||
cursor.drawText(
|
cursor.drawText(
|
||||||
text = labelText,
|
text = labelText,
|
||||||
paint = paints.item,
|
paint = paints.item,
|
||||||
indent = CHECKBOX_INDENT,
|
indent = ITEM_INDENT,
|
||||||
spacingAfter = 3f,
|
spacingAfter = 3f,
|
||||||
leadingMark = { canvas, left, top, bottom ->
|
|
||||||
drawCheckboxGlyph(canvas, left, top, bottom, checked, paints)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Freitext nur zeichnen, wenn die Option angehakt UND tatsächlich Text vorhanden ist -
|
// Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
|
||||||
// ein angehakter, aber leerer Freitext erzeugt keinen leeren Bereich im PDF.
|
// leerer Freitext erzeugt keinen leeren Bereich im PDF.
|
||||||
if (item.isFreeText && checked && item.freeTextValue.isNotBlank()) {
|
if (item.isFreeText && item.freeTextValue.isNotBlank()) {
|
||||||
cursor.drawText(
|
cursor.drawText(
|
||||||
text = item.freeTextValue,
|
text = item.freeTextValue,
|
||||||
paint = paints.freeText,
|
paint = paints.freeText,
|
||||||
@@ -321,33 +303,6 @@ class AndroidPdfDocumentExporter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Zeichnet ein Checkbox-Symbol rein per Canvas-Vektorprimitiven: ein Quadrat-Umriss, bei
|
|
||||||
* `checked = true` zusätzlich ein Kreuz darin. [top]/[bottom] sind die Geräte-Y-Koordinaten
|
|
||||||
* der zugehörigen ersten Textzeile - das Kästchen wird vertikal darauf zentriert.
|
|
||||||
*/
|
|
||||||
private fun drawCheckboxGlyph(
|
|
||||||
canvas: Canvas,
|
|
||||||
left: Float,
|
|
||||||
top: Float,
|
|
||||||
bottom: Float,
|
|
||||||
checked: Boolean,
|
|
||||||
paints: ExportPaints,
|
|
||||||
) {
|
|
||||||
val centerY = (top + bottom) / 2f
|
|
||||||
val boxTop = centerY - CHECKBOX_SIZE / 2f
|
|
||||||
val boxBottom = centerY + CHECKBOX_SIZE / 2f
|
|
||||||
val boxRight = left + CHECKBOX_SIZE
|
|
||||||
|
|
||||||
canvas.drawRect(left, boxTop, boxRight, boxBottom, paints.checkboxOutline)
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
val inset = CHECKBOX_SIZE * 0.2f
|
|
||||||
canvas.drawLine(left + inset, boxTop + inset, boxRight - inset, boxBottom - inset, paints.checkboxMark)
|
|
||||||
canvas.drawLine(boxRight - inset, boxTop + inset, left + inset, boxBottom - inset, paints.checkboxMark)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
|
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
|
||||||
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
|
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ object DocumentationToPdfExportModelMapper {
|
|||||||
.map { block ->
|
.map { block ->
|
||||||
PdfExportBlock(
|
PdfExportBlock(
|
||||||
title = block.title,
|
title = block.title,
|
||||||
|
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
|
||||||
|
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
|
||||||
|
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
|
||||||
|
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
|
||||||
items = block.items
|
items = block.items
|
||||||
|
.filter { it.checked }
|
||||||
.sortedBy { it.order }
|
.sortedBy { it.order }
|
||||||
.map { item ->
|
.map { item ->
|
||||||
PdfExportItem(
|
PdfExportItem(
|
||||||
label = item.label,
|
label = item.label,
|
||||||
checked = item.checked,
|
|
||||||
isFreeText = item.isFreeText,
|
isFreeText = item.isFreeText,
|
||||||
freeTextValue = item.freeTextValue,
|
freeTextValue = item.freeTextValue,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,14 +28,15 @@ data class PdfExportModel(
|
|||||||
|
|
||||||
data class PdfExportBlock(
|
data class PdfExportBlock(
|
||||||
val title: String,
|
val title: String,
|
||||||
/** Bereits sortiert nach itemOrder. Enthält ALLE Optionen des Blocks (angehakt und nicht
|
/** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
|
||||||
* angehakt) - siehe Architekturentscheidung zur Checkbox-Darstellung. */
|
* (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
|
||||||
val items: List<PdfExportItem>,
|
val items: List<PdfExportItem>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
|
||||||
|
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter. */
|
||||||
data class PdfExportItem(
|
data class PdfExportItem(
|
||||||
val label: String,
|
val label: String,
|
||||||
val checked: Boolean,
|
|
||||||
val isFreeText: Boolean,
|
val isFreeText: Boolean,
|
||||||
val freeTextValue: String,
|
val freeTextValue: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übergibt alle als JPEG gerenderten PDF-Seiten (s. [PdfToImagesConverter]) GEMEINSAM an den
|
||||||
|
* Android-"Senden an"-Dialog (`ACTION_SEND_MULTIPLE`) - Änderungswunsch: "alle generierten
|
||||||
|
* Bilder über 'Senden an' direkt verschicken können", nicht Seite für Seite einzeln.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt von [PdfToImagesConverter] (reine Datei-Logik): das Starten einer Activity
|
||||||
|
* ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe - exakt dieselbe Trennung wie bei
|
||||||
|
* [DocumentationPdfExporter] (Erzeugung) vs. [PdfOpener] (Anzeige).
|
||||||
|
*
|
||||||
|
* VulneraDoc implementiert ausdrücklich KEINEN eigenen Versand-Mechanismus; welche konkrete App
|
||||||
|
* (Mail, Messenger, Cloud-Speicher, ...) die Bilder entgegennimmt, entscheidet ausschließlich
|
||||||
|
* Android/der Nutzer über den System-Dialog.
|
||||||
|
*/
|
||||||
|
interface PdfImageSharer {
|
||||||
|
fun share(imageFiles: List<File>): PdfImageShareResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PdfImageShareResult {
|
||||||
|
data object Success : PdfImageShareResult
|
||||||
|
|
||||||
|
sealed interface Failure : PdfImageShareResult {
|
||||||
|
/** Es wurden keine Bilddateien übergeben - nichts zum Teilen vorhanden. */
|
||||||
|
data object NoImages : Failure
|
||||||
|
|
||||||
|
/** FileProvider konnte für mindestens eine Datei keine content://-Uri erzeugen. */
|
||||||
|
data class UriResolutionFailed(val message: String) : Failure
|
||||||
|
|
||||||
|
/** Keine installierte App kann Bilder (image/jpeg) entgegennehmen. */
|
||||||
|
data object NoAppAvailable : Failure
|
||||||
|
|
||||||
|
data class Unexpected(val message: String) : Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PdfImageShareResult.Failure.toUserMessage(): String = when (this) {
|
||||||
|
PdfImageShareResult.Failure.NoImages ->
|
||||||
|
"Es sind keine Bilder zum Versenden vorhanden."
|
||||||
|
is PdfImageShareResult.Failure.UriResolutionFailed ->
|
||||||
|
"Die Bilder konnten nicht zum Versenden freigegeben werden."
|
||||||
|
PdfImageShareResult.Failure.NoAppAvailable ->
|
||||||
|
"Auf diesem Gerät ist keine App zum Versenden von Bildern installiert."
|
||||||
|
is PdfImageShareResult.Failure.Unexpected ->
|
||||||
|
"Die Bilder konnten nicht versendet werden."
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidPdfImageSharer(private val context: Context) : PdfImageSharer {
|
||||||
|
|
||||||
|
override fun share(imageFiles: List<File>): PdfImageShareResult {
|
||||||
|
if (imageFiles.isEmpty()) return PdfImageShareResult.Failure.NoImages
|
||||||
|
|
||||||
|
val authority = "${context.packageName}.fileprovider"
|
||||||
|
val uris = try {
|
||||||
|
imageFiles.map { file -> FileProvider.getUriForFile(context, authority, file) }
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// Wird von FileProvider geworfen, wenn eine Datei außerhalb der in file_paths.xml
|
||||||
|
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
|
||||||
|
return PdfImageShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||||
|
type = "image/jpeg"
|
||||||
|
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
// Zusätzlich zum Flag oben wird die Leseberechtigung explizit über ClipData an JEDE
|
||||||
|
// einzelne Uri gehängt - bei ACTION_SEND_MULTIPLE mit mehreren content://-Uris ist
|
||||||
|
// das der zuverlässige Weg, damit wirklich ALLE empfangenden Apps Zugriff auf ALLE
|
||||||
|
// Bilder bekommen (nicht nur auf die im Intent-Datenfeld primär referenzierte).
|
||||||
|
clipData = ClipData.newUri(context.contentResolver, "Wunddokumentation", uris.first()).apply {
|
||||||
|
for (uri in uris.drop(1)) {
|
||||||
|
addItem(ClipData.Item(uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val chooser = Intent.createChooser(intent, "Bilder senden an")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
context.startActivity(chooser)
|
||||||
|
PdfImageShareResult.Success
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
|
||||||
|
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
|
||||||
|
PdfImageShareResult.Failure.NoAppAvailable
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
PdfImageShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die Bilddateien wurde verweigert.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PdfImageShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der Bilder.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übergibt die fertige PDF-Datei selbst (statt in JPEG-Seiten umgewandelt, s.
|
||||||
|
* [PdfToImagesConverter]/[PdfImageSharer]) an den Android-"Senden an"-Dialog
|
||||||
|
* (`ACTION_SEND`, `application/pdf`) - Änderungswunsch: Nutzer soll beim Antippen des
|
||||||
|
* Teilen-Buttons wählen können, ob als JPG ODER als PDF geteilt wird.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt von [PdfOpener] (öffnet dieselbe Datei stattdessen per `ACTION_VIEW` in
|
||||||
|
* einem PDF-Reader) - unterschiedliche Nutzerabsicht (ansehen vs. weitergeben), auch wenn
|
||||||
|
* beide dieselbe Datei über denselben FileProvider-Pfad freigeben.
|
||||||
|
*/
|
||||||
|
interface PdfSharer {
|
||||||
|
fun share(pdfFile: File): PdfShareResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PdfShareResult {
|
||||||
|
data object Success : PdfShareResult
|
||||||
|
|
||||||
|
sealed interface Failure : PdfShareResult {
|
||||||
|
data class UriResolutionFailed(val message: String) : Failure
|
||||||
|
data object NoAppAvailable : Failure
|
||||||
|
data class Unexpected(val message: String) : Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PdfShareResult.Failure.toUserMessage(): String = when (this) {
|
||||||
|
is PdfShareResult.Failure.UriResolutionFailed ->
|
||||||
|
"Die PDF-Datei konnte nicht zum Versenden freigegeben werden."
|
||||||
|
PdfShareResult.Failure.NoAppAvailable ->
|
||||||
|
"Auf diesem Gerät ist keine App zum Versenden von PDFs installiert."
|
||||||
|
is PdfShareResult.Failure.Unexpected ->
|
||||||
|
"Die PDF-Datei konnte nicht versendet werden."
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidPdfSharer(private val context: Context) : PdfSharer {
|
||||||
|
|
||||||
|
override fun share(pdfFile: File): PdfShareResult {
|
||||||
|
val authority = "${context.packageName}.fileprovider"
|
||||||
|
val uri = try {
|
||||||
|
FileProvider.getUriForFile(context, authority, pdfFile)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
return PdfShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/pdf"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
val chooser = Intent.createChooser(intent, "PDF senden an")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
context.startActivity(chooser)
|
||||||
|
PdfShareResult.Success
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
|
||||||
|
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
|
||||||
|
PdfShareResult.Failure.NoAppAvailable
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
PdfShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PdfShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der PDF-Datei.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert eine bereits fertig erzeugte PDF-Datei (s. [DocumentationPdfExporter]) seitenweise in
|
||||||
|
* einzelne JPEG-Dateien - Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können".
|
||||||
|
*
|
||||||
|
* Bewusst als eigener Schritt NACH dem PDF-Export statt als Alternative dazu: Es gibt weiterhin
|
||||||
|
* genau eine Quelle der Wahrheit für das Seitenlayout (der bestehende [PdfPageCursor]-basierte
|
||||||
|
* Export) - dieser Konverter rastert lediglich das bereits fertige Ergebnis, statt eine zweite,
|
||||||
|
* parallele Layout-Logik zu pflegen.
|
||||||
|
*
|
||||||
|
* Verwendet [android.graphics.pdf.PdfRenderer] (Android-Bordmittel seit API 21, unproblematisch
|
||||||
|
* bei minSdk 26) - keine zusätzliche Bibliothek nötig. Eine mehrseitige Wunddokumentation (Kopf/
|
||||||
|
* Blöcke, Signaturen, ggf. 30+ Fotoseiten) ergibt dabei bewusst EIN JPEG PRO SEITE, kein
|
||||||
|
* einzelnes Sammelbild - beim späteren Teilen (s. [PdfImageSharer]) werden alle Seiten
|
||||||
|
* gemeinsam als Bilder-Mehrfachauswahl übergeben.
|
||||||
|
*/
|
||||||
|
interface PdfToImagesConverter {
|
||||||
|
suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PdfToImagesResult {
|
||||||
|
data class Success(val imageFiles: List<File>) : PdfToImagesResult
|
||||||
|
|
||||||
|
sealed interface Failure : PdfToImagesResult {
|
||||||
|
/** Die übergebene PDF-Datei existiert nicht (mehr). */
|
||||||
|
data object FileNotFound : Failure
|
||||||
|
|
||||||
|
/** PdfRenderer meldet 0 Seiten - sollte bei einem selbst erzeugten PDF nie vorkommen. */
|
||||||
|
data object EmptyDocument : Failure
|
||||||
|
|
||||||
|
data class Unexpected(val message: String) : Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
|
||||||
|
PdfToImagesResult.Failure.FileNotFound ->
|
||||||
|
"Die PDF-Datei wurde nicht gefunden. Bitte zuerst die Dokumentation erneut exportieren."
|
||||||
|
PdfToImagesResult.Failure.EmptyDocument ->
|
||||||
|
"Die PDF-Datei enthält keine Seiten."
|
||||||
|
is PdfToImagesResult.Failure.Unexpected ->
|
||||||
|
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
|
||||||
|
|
||||||
|
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (!pdfFile.exists()) return@withContext PdfToImagesResult.Failure.FileNotFound
|
||||||
|
|
||||||
|
val outputDir = File(File(context.filesDir, "pdf_images"), documentationId)
|
||||||
|
// Bilder eines vorherigen Exports zuerst entfernen, damit bei einer inzwischen
|
||||||
|
// kürzeren PDF (z. B. Fotos gelöscht) keine veralteten, zu vielen Seiten-Dateien
|
||||||
|
// liegen bleiben.
|
||||||
|
outputDir.deleteRecursively()
|
||||||
|
outputDir.mkdirs()
|
||||||
|
|
||||||
|
try {
|
||||||
|
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
|
||||||
|
PdfRenderer(fd).use { renderer ->
|
||||||
|
if (renderer.pageCount == 0) {
|
||||||
|
return@withContext PdfToImagesResult.Failure.EmptyDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
val imageFiles = mutableListOf<File>()
|
||||||
|
for (pageIndex in 0 until renderer.pageCount) {
|
||||||
|
renderer.openPage(pageIndex).use { page ->
|
||||||
|
val scale = RENDER_DPI / POINTS_PER_INCH
|
||||||
|
val width = (page.width * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
val height = (page.height * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
// WICHTIG: PdfRenderer zeichnet nur die tatsächlich im PDF
|
||||||
|
// vorhandenen Inhalte - unbedeckte Flächen bleiben transparent
|
||||||
|
// (Alpha 0). JPEG kennt keine Transparenz; ohne dieses
|
||||||
|
// Vorab-Weißfüllen würde jede unbedeckte Fläche beim
|
||||||
|
// Komprimieren als SCHWARZ statt als weißer Seitenhintergrund
|
||||||
|
// enden (bekannte PdfRenderer-Falle, s. offizielle Android-
|
||||||
|
// Beispiele).
|
||||||
|
bitmap.eraseColor(Color.WHITE)
|
||||||
|
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
|
||||||
|
val imageFile = File(outputDir, "seite_%02d.jpg".format(pageIndex + 1))
|
||||||
|
FileOutputStream(imageFile).use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
imageFiles.add(imageFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PdfToImagesResult.Success(imageFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
|
||||||
|
* ein für Ausdruck/Weitergabe ausreichend scharfes, aber nicht unnötig großes Bild. */
|
||||||
|
private const val RENDER_DPI = 200f
|
||||||
|
private const val POINTS_PER_INCH = 72f
|
||||||
|
private const val JPEG_QUALITY = 90
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
-49
@@ -7,6 +7,7 @@ import androidx.activity.result.PickVisualMediaRequest
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -24,8 +26,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.BrokenImage
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.PictureAsPdf
|
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
@@ -41,6 +47,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -55,11 +62,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -70,8 +79,12 @@ 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.domain.model.Signature
|
||||||
|
import de.medapp.wunddoku.pdf.AndroidPdfImageSharer
|
||||||
import de.medapp.wunddoku.pdf.AndroidPdfOpener
|
import de.medapp.wunddoku.pdf.AndroidPdfOpener
|
||||||
|
import de.medapp.wunddoku.pdf.AndroidPdfSharer
|
||||||
|
import de.medapp.wunddoku.pdf.PdfImageShareResult
|
||||||
import de.medapp.wunddoku.pdf.PdfOpenResult
|
import de.medapp.wunddoku.pdf.PdfOpenResult
|
||||||
|
import de.medapp.wunddoku.pdf.PdfShareResult
|
||||||
import de.medapp.wunddoku.pdf.toUserMessage
|
import de.medapp.wunddoku.pdf.toUserMessage
|
||||||
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
|
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
|
||||||
|
|
||||||
@@ -95,6 +108,13 @@ fun DocumentationEditScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Vollbildansicht eines Fotos beim Antippen einer Kachel (Änderungswunsch) - rein lokaler
|
||||||
|
// UI-Zustand, bewusst nicht im ViewModel gehalten (analog zu den transient geladenen
|
||||||
|
// Thumbnail-/Signatur-Bitmaps, s. Kommentare an PhotoThumbnail/loadPhotoThumbnail): welches
|
||||||
|
// Foto gerade großformatig angezeigt wird, ist reine Präsentationslogik ohne fachliche
|
||||||
|
// Bedeutung, die einen Prozesstod überdauern müsste.
|
||||||
|
var viewedPhoto by remember { mutableStateOf<Photo?>(null) }
|
||||||
|
|
||||||
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
||||||
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
|
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
|
||||||
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
|
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
|
||||||
@@ -125,6 +145,8 @@ fun DocumentationEditScreen(
|
|||||||
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
|
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
|
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
|
||||||
|
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
|
||||||
|
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
LaunchedEffect(uiState.pdfExportMessage) {
|
LaunchedEffect(uiState.pdfExportMessage) {
|
||||||
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
||||||
@@ -145,6 +167,34 @@ fun DocumentationEditScreen(
|
|||||||
viewModel.dismissPdfExportMessage()
|
viewModel.dismissPdfExportMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
|
||||||
|
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s.
|
||||||
|
// DocumentationEditViewModel.shareAsImages), öffnet dieser Effekt den System-
|
||||||
|
// Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
|
||||||
|
LaunchedEffect(uiState.imagesToShare) {
|
||||||
|
val images = uiState.imagesToShare ?: return@LaunchedEffect
|
||||||
|
when (val shareResult = pdfImageSharer.share(images)) {
|
||||||
|
PdfImageShareResult.Success -> Unit
|
||||||
|
is PdfImageShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
|
||||||
|
}
|
||||||
|
viewModel.onImageShareHandled()
|
||||||
|
}
|
||||||
|
// Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s.
|
||||||
|
// DocumentationEditViewModel.shareAsPdf).
|
||||||
|
LaunchedEffect(uiState.pdfToShare) {
|
||||||
|
val pdfFile = uiState.pdfToShare ?: return@LaunchedEffect
|
||||||
|
when (val shareResult = pdfSharer.share(pdfFile)) {
|
||||||
|
PdfShareResult.Success -> Unit
|
||||||
|
is PdfShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
|
||||||
|
}
|
||||||
|
viewModel.onPdfShareHandled()
|
||||||
|
}
|
||||||
|
LaunchedEffect(uiState.shareMessage) {
|
||||||
|
val message = uiState.shareMessage ?: return@LaunchedEffect
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
viewModel.dismissShareMessage()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -153,8 +203,8 @@ fun DocumentationEditScreen(
|
|||||||
if (uiState.blockCount > 0) {
|
if (uiState.blockCount > 0) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.documentation_edit_position,
|
R.string.documentation_edit_progress,
|
||||||
uiState.currentBlockIndex + 1,
|
uiState.editedBlockCount,
|
||||||
uiState.blockCount,
|
uiState.blockCount,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -163,9 +213,8 @@ fun DocumentationEditScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt
|
// PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
|
||||||
// ausschließlich die Datei im internen Speicher (PDF-001/002), als
|
// erzeugt die Datei im internen Speicher (PDF-001/002).
|
||||||
// minimaler Trigger zum manuellen Testen der Export-Funktionalität.
|
|
||||||
if (uiState.pdfExportInProgress) {
|
if (uiState.pdfExportInProgress) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
@@ -173,6 +222,16 @@ fun DocumentationEditScreen(
|
|||||||
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
|
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden, wahlweise
|
||||||
|
// auch als PDF selbst - Format wird per Dialog abgefragt (s. weiter unten),
|
||||||
|
// BEVOR irgendetwas exportiert wird.
|
||||||
|
if (uiState.shareInProgress) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = viewModel::requestShareFormatChoice) {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = "Teilen")
|
||||||
|
}
|
||||||
|
}
|
||||||
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
|
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
|
||||||
// Autosave gesichert, kein expliziter Speichern-Button nötig).
|
// Autosave gesichert, kein expliziter Speichern-Button nötig).
|
||||||
Button(onClick = onFinished) { Text("Fertig") }
|
Button(onClick = onFinished) { Text("Fertig") }
|
||||||
@@ -193,23 +252,20 @@ fun DocumentationEditScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val documentation = uiState.documentation
|
val documentation = uiState.documentation
|
||||||
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex)
|
if (documentation == null || documentation.blocks.isEmpty()) {
|
||||||
if (documentation == null || currentBlock == null) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||||
Text("Diese Dokumentation enthält keine Blöcke.")
|
Text("Diese Dokumentation enthält keine Blöcke.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DocumentationEditContent(
|
DocumentationEditContent(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
block = currentBlock,
|
blocks = documentation.blocks,
|
||||||
|
expandedBlockId = uiState.expandedBlockId,
|
||||||
|
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
|
||||||
photos = documentation.photos,
|
photos = documentation.photos,
|
||||||
signatures = documentation.signatures,
|
signatures = documentation.signatures,
|
||||||
onCheckedChanged = viewModel::onCheckedChanged,
|
onCheckedChanged = viewModel::onCheckedChanged,
|
||||||
onFreeTextChanged = viewModel::onFreeTextChanged,
|
onFreeTextChanged = viewModel::onFreeTextChanged,
|
||||||
canGoBack = uiState.currentBlockIndex > 0,
|
|
||||||
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
|
|
||||||
onPrevious = viewModel::onPreviousBlock,
|
|
||||||
onNext = viewModel::onNextBlock,
|
|
||||||
photoImportInProgress = uiState.photoImportInProgress,
|
photoImportInProgress = uiState.photoImportInProgress,
|
||||||
photoImportError = uiState.photoImportError,
|
photoImportError = uiState.photoImportError,
|
||||||
onDismissPhotoImportError = viewModel::dismissPhotoImportError,
|
onDismissPhotoImportError = viewModel::dismissPhotoImportError,
|
||||||
@@ -220,6 +276,7 @@ fun DocumentationEditScreen(
|
|||||||
},
|
},
|
||||||
onDeletePhotoRequested = viewModel::requestDeletePhoto,
|
onDeletePhotoRequested = viewModel::requestDeletePhoto,
|
||||||
loadThumbnail = viewModel::loadPhotoThumbnail,
|
loadThumbnail = viewModel::loadPhotoThumbnail,
|
||||||
|
onPhotoClick = { viewedPhoto = it },
|
||||||
onAddSignatureClick = viewModel::requestAddSignature,
|
onAddSignatureClick = viewModel::requestAddSignature,
|
||||||
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
|
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
|
||||||
loadSignatureBitmap = viewModel::loadSignatureBitmap,
|
loadSignatureBitmap = viewModel::loadSignatureBitmap,
|
||||||
@@ -295,32 +352,90 @@ fun DocumentationEditScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Änderungswunsch: Foto in Originalgröße anzeigen.
|
||||||
|
val photoToView = viewedPhoto
|
||||||
|
if (photoToView != null) {
|
||||||
|
PhotoViewerDialog(
|
||||||
|
photo = photoToView,
|
||||||
|
loadFullSize = viewModel::loadPhotoFullSize,
|
||||||
|
onDismiss = { viewedPhoto = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons.
|
||||||
|
if (uiState.showShareFormatDialog) {
|
||||||
|
ShareFormatDialog(
|
||||||
|
onDismiss = viewModel::dismissShareFormatDialog,
|
||||||
|
onChooseImages = viewModel::shareAsImages,
|
||||||
|
onChoosePdf = viewModel::shareAsPdf,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: aktueller Block (Titel, Optionen,
|
* Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als
|
||||||
* Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als
|
* PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird
|
||||||
|
* überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) -
|
||||||
|
* kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ShareFormatDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChooseImages: () -> Unit,
|
||||||
|
onChoosePdf: () -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
|
||||||
|
Text("Teilen als", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Als Bilder (JPG) oder als PDF teilen?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
OutlinedButton(onClick = onChooseImages) { Text("JPG") }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(onClick = onChoosePdf) { Text("PDF") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
|
||||||
|
* (Änderungswunsch) - zunächst stehen nur die Überschriften untereinander, Antippen einer
|
||||||
|
* Überschrift klappt genau diesen Block auf und schließt dabei automatisch einen zuvor
|
||||||
|
* aufgeklappten (s. [DocumentationEditViewModel.toggleBlockExpanded] - IMMER höchstens ein
|
||||||
|
* Block gleichzeitig offen) - gefolgt vom dokumentationsweiten Signatur- und Fotobereich. Als
|
||||||
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
|
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
|
||||||
* sind/werden.
|
* sind/werden.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DocumentationEditContent(
|
private fun DocumentationEditContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
block: DocumentationBlock,
|
blocks: List<DocumentationBlock>,
|
||||||
|
expandedBlockId: String?,
|
||||||
|
onToggleBlockExpanded: (blockStateId: String) -> Unit,
|
||||||
photos: List<Photo>,
|
photos: List<Photo>,
|
||||||
signatures: List<Signature>,
|
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,
|
|
||||||
canGoNext: Boolean,
|
|
||||||
onPrevious: () -> Unit,
|
|
||||||
onNext: () -> Unit,
|
|
||||||
photoImportInProgress: Boolean,
|
photoImportInProgress: Boolean,
|
||||||
photoImportError: String?,
|
photoImportError: String?,
|
||||||
onDismissPhotoImportError: () -> Unit,
|
onDismissPhotoImportError: () -> Unit,
|
||||||
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?,
|
||||||
|
onPhotoClick: (Photo) -> Unit,
|
||||||
onAddSignatureClick: () -> Unit,
|
onAddSignatureClick: () -> Unit,
|
||||||
onDeleteSignatureRequested: (signatureId: String) -> Unit,
|
onDeleteSignatureRequested: (signatureId: String) -> Unit,
|
||||||
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
@@ -330,34 +445,14 @@ private fun DocumentationEditContent(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item(key = "block_title") {
|
items(blocks, key = { it.blockStateId }) { block ->
|
||||||
Text(block.title, style = MaterialTheme.typography.headlineSmall)
|
BlockAccordionItem(
|
||||||
}
|
block = block,
|
||||||
|
expanded = block.blockStateId == expandedBlockId,
|
||||||
items(block.items, key = { it.checkItemStateId }) { item ->
|
onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
|
||||||
if (item.isFreeText) {
|
onCheckedChanged = onCheckedChanged,
|
||||||
FreeTextCheckItemRow(
|
onFreeTextChanged = onFreeTextChanged,
|
||||||
item = item,
|
)
|
||||||
onCheckedChanged = onCheckedChanged,
|
|
||||||
onFreeTextChanged = onFreeTextChanged,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "block_navigation") {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
|
|
||||||
Text(stringResource(R.string.documentation_edit_previous))
|
|
||||||
}
|
|
||||||
Button(onClick = onNext, enabled = canGoNext) {
|
|
||||||
Text(stringResource(R.string.documentation_edit_next))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
|
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
|
||||||
@@ -451,7 +546,12 @@ private fun DocumentationEditContent(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
|
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
|
||||||
PhotoRow(photos = row, loadThumbnail = loadThumbnail, onDeleteRequested = onDeletePhotoRequested)
|
PhotoRow(
|
||||||
|
photos = row,
|
||||||
|
loadThumbnail = loadThumbnail,
|
||||||
|
onDeleteRequested = onDeletePhotoRequested,
|
||||||
|
onPhotoClick = onPhotoClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,6 +564,7 @@ private fun PhotoRow(
|
|||||||
photos: List<Photo>,
|
photos: List<Photo>,
|
||||||
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
onDeleteRequested: (photoId: String) -> Unit,
|
onDeleteRequested: (photoId: String) -> Unit,
|
||||||
|
onPhotoClick: (Photo) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
photos.forEach { photo ->
|
photos.forEach { photo ->
|
||||||
@@ -471,6 +572,7 @@ private fun PhotoRow(
|
|||||||
photo = photo,
|
photo = photo,
|
||||||
loadThumbnail = loadThumbnail,
|
loadThumbnail = loadThumbnail,
|
||||||
onDeleteRequested = { onDeleteRequested(photo.id) },
|
onDeleteRequested = { onDeleteRequested(photo.id) },
|
||||||
|
onClick = { onPhotoClick(photo) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,12 +585,17 @@ private fun PhotoRow(
|
|||||||
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
|
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
|
||||||
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
|
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
|
||||||
* sichtbar ist), wird der Zustand automatisch freigegeben.
|
* sichtbar ist), wird der Zustand automatisch freigegeben.
|
||||||
|
*
|
||||||
|
* Ein Antippen der Kachel (außerhalb des Löschen-Buttons) öffnet die Vollbildansicht
|
||||||
|
* (Änderungswunsch) - [onClick] statt eines eigenen Ladevorgangs hier, da die Kachel selbst nur
|
||||||
|
* das kleine, bereits geladene Thumbnail kennt.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhotoThumbnail(
|
private fun PhotoThumbnail(
|
||||||
photo: Photo,
|
photo: Photo,
|
||||||
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
onDeleteRequested: () -> Unit,
|
onDeleteRequested: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
|
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
var loadFailed by remember(photo.id) { mutableStateOf(false) }
|
var loadFailed by remember(photo.id) { mutableStateOf(false) }
|
||||||
@@ -503,7 +610,8 @@ private fun PhotoThumbnail(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(96.dp)
|
.size(96.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(enabled = bitmap != null, onClick = onClick),
|
||||||
) {
|
) {
|
||||||
val currentBitmap = bitmap
|
val currentBitmap = bitmap
|
||||||
when {
|
when {
|
||||||
@@ -611,6 +719,73 @@ private fun SignatureRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Block im Akkordeon (Änderungswunsch): eine antippbare Kopfzeile mit Titel,
|
||||||
|
* einem kleinen "bearbeitet"-Indikator (grüner Haken, s. [DocumentationBlock.isEdited]) und
|
||||||
|
* einem Pfeil-Icon, das den Auf-/Zugeklappt-Zustand anzeigt. Der eigentliche Inhalt (Checkboxen
|
||||||
|
* inkl. Freitext-Option) wird ausschließlich gerendert, wenn [expanded] `true` ist - das
|
||||||
|
* entspricht fachlich einem Dropdown/Expansion-Panel, technisch einfach bedingtes Komponieren
|
||||||
|
* ohne eigene Lazy-Liste (ein Block hat nur wenige Optionen).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BlockAccordionItem(
|
||||||
|
block: DocumentationBlock,
|
||||||
|
expanded: Boolean,
|
||||||
|
onHeaderClick: () -> Unit,
|
||||||
|
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||||
|
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onHeaderClick)
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (block.isEdited) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = stringResource(R.string.documentation_edit_block_edited),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
block.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
for (item in block.items) {
|
||||||
|
when {
|
||||||
|
item.isStaticHint -> StaticHintRow(item = item)
|
||||||
|
item.isFreeText -> FreeTextCheckItemRow(
|
||||||
|
item = item,
|
||||||
|
onCheckedChanged = onCheckedChanged,
|
||||||
|
onFreeTextChanged = onFreeTextChanged,
|
||||||
|
)
|
||||||
|
else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CheckItemRow(
|
private fun CheckItemRow(
|
||||||
item: CheckItem,
|
item: CheckItem,
|
||||||
@@ -654,3 +829,18 @@ private fun FreeTextCheckItemRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein rein anzeigender, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" bei "Empfohlene
|
||||||
|
* Wundversorgung") - keine Checkbox, keine Interaktion, [item.label] ist direkt der
|
||||||
|
* anzuzeigende Text selbst. Optisch dezent (kursiv, gedämpfte Farbe) abgesetzt von echten,
|
||||||
|
* ausfüllbaren Optionen, damit auf den ersten Blick klar ist, dass hier nichts einzugeben ist.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StaticHintRow(item: CheckItem) {
|
||||||
|
Text(
|
||||||
|
item.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+164
-11
@@ -15,6 +15,8 @@ import de.medapp.wunddoku.domain.model.Documentation
|
|||||||
import de.medapp.wunddoku.domain.model.WoundCarer
|
import de.medapp.wunddoku.domain.model.WoundCarer
|
||||||
import de.medapp.wunddoku.pdf.PdfExportError
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||||
|
import de.medapp.wunddoku.pdf.toUserMessage
|
||||||
import de.medapp.wunddoku.ui.navigation.Screen
|
import de.medapp.wunddoku.ui.navigation.Screen
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -28,7 +30,11 @@ import java.io.File
|
|||||||
|
|
||||||
data class DocumentationEditUiState(
|
data class DocumentationEditUiState(
|
||||||
val documentation: Documentation? = null,
|
val documentation: Documentation? = null,
|
||||||
val currentBlockIndex: Int = 0,
|
/** ID des BlockState, der gerade im Akkordeon aufgeklappt ist - `null`, wenn keiner
|
||||||
|
* aufgeklappt ist (Änderungswunsch: zunächst nur alle Überschriften untereinander, Antippen
|
||||||
|
* einer Überschrift klappt sie auf). Es ist IMMER höchstens ein Block gleichzeitig
|
||||||
|
* aufgeklappt, s. [DocumentationEditViewModel.toggleBlockExpanded]. */
|
||||||
|
val expandedBlockId: String? = null,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
|
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
|
||||||
@@ -59,8 +65,37 @@ data class DocumentationEditUiState(
|
|||||||
* referenziert werden kann, während ein neuer Export läuft.
|
* referenziert werden kann, während ein neuer Export läuft.
|
||||||
*/
|
*/
|
||||||
val lastExportedPdfFile: File? = null,
|
val lastExportedPdfFile: File? = null,
|
||||||
|
/** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer
|
||||||
|
* Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages],
|
||||||
|
* PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert
|
||||||
|
* wird. */
|
||||||
|
val shareInProgress: Boolean = false,
|
||||||
|
/** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - ein erfolgreicher
|
||||||
|
* Export löst dagegen KEINE Meldung aus, sondern direkt [imagesToShare] bzw. [pdfToShare]. */
|
||||||
|
val shareMessage: String? = null,
|
||||||
|
/** true, solange die Auswahl "Als JPG oder PDF teilen?" angezeigt wird (Änderungswunsch:
|
||||||
|
* Format-Auswahl beim Antippen des Teilen-Buttons, bevor irgendetwas exportiert wird). */
|
||||||
|
val showShareFormatDialog: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder
|
||||||
|
* über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect
|
||||||
|
* beobachtet, der daraufhin den System-"Senden an"-Dialog öffnet und anschließend
|
||||||
|
* [DocumentationEditViewModel.onImageShareHandled] aufruft - das Teilen selbst (Intent/
|
||||||
|
* Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
|
||||||
|
*/
|
||||||
|
val imagesToShare: List<File>? = null,
|
||||||
|
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
|
||||||
|
* [DocumentationEditViewModel.onPdfShareHandled]. */
|
||||||
|
val pdfToShare: File? = null,
|
||||||
) {
|
) {
|
||||||
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
||||||
|
|
||||||
|
/** Anzahl bereits bearbeiteter Blöcke (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy])
|
||||||
|
* - ersetzt die frühere "X / Y"-Positionsanzeige, die sich auf den einzelnen, gerade
|
||||||
|
* angezeigten Block bezog; im Akkordeon-Layout sind ALLE Blöcke gleichzeitig sichtbar
|
||||||
|
* (nur der Inhalt ist ein-/ausklappbar), daher ist ein Fortschritt sinnvoller als eine
|
||||||
|
* Position. */
|
||||||
|
val editedBlockCount: Int get() = documentation?.blocks?.count { it.isEdited } ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,17 +198,17 @@ class DocumentationEditViewModel(
|
|||||||
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
|
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onNextBlock() {
|
/**
|
||||||
|
* Klappt den Block mit [blockStateId] auf bzw. zu (Änderungswunsch: Akkordeon statt
|
||||||
|
* Vor-/Zurück-Navigation). Antippen eines bereits aufgeklappten Blocks klappt ihn zu;
|
||||||
|
* Antippen eines anderen Blocks klappt diesen auf und ersetzt dabei automatisch einen zuvor
|
||||||
|
* aufgeklappten Block - es ist also IMMER höchstens ein Block gleichzeitig aufgeklappt,
|
||||||
|
* ganz ohne zusätzliche Schließen-Logik für den vorherigen.
|
||||||
|
*/
|
||||||
|
fun toggleBlockExpanded(blockStateId: String) {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val lastIndex = state.blockCount - 1
|
val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
|
||||||
if (lastIndex < 0) return@update state
|
state.copy(expandedBlockId = newExpandedId)
|
||||||
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPreviousBlock() {
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +381,120 @@ class DocumentationEditViewModel(
|
|||||||
_uiState.update { it.copy(pdfExportMessage = null) }
|
_uiState.update { it.copy(pdfExportMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet die Auswahl "Als JPG oder PDF teilen?" (Änderungswunsch: Format-Auswahl beim
|
||||||
|
* Antippen des Teilen-Buttons, BEVOR irgendetwas exportiert wird - vorher wurde beim
|
||||||
|
* Antippen sofort und ausschließlich als JPG geteilt).
|
||||||
|
*/
|
||||||
|
fun requestShareFormatChoice() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissShareFormatDialog() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nutzer hat sich für "Als JPG teilen" entschieden. */
|
||||||
|
fun shareAsImages() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = false) }
|
||||||
|
exportAndShareAsImages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nutzer hat sich für "Als PDF teilen" entschieden. */
|
||||||
|
fun shareAsPdf() {
|
||||||
|
_uiState.update { it.copy(showShareFormatDialog = false) }
|
||||||
|
exportAndSharePdf()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportiert die Dokumentation frisch als PDF und wandelt das Ergebnis anschließend
|
||||||
|
* seitenweise in JPEG-Bilder um (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden
|
||||||
|
* können"). Bewusst ein eigener, vollständiger Export-Lauf statt Wiederverwendung von
|
||||||
|
* [uiState.lastExportedPdfFile]: so spiegeln die Bilder IMMER den aktuellen Stand wider,
|
||||||
|
* unabhängig davon, ob/wann zuvor schon einmal "PDF erzeugen" angetippt wurde.
|
||||||
|
*
|
||||||
|
* Das eigentliche Teilen (Android-"Senden an"-Dialog) übernimmt NICHT diese Methode, sondern
|
||||||
|
* der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
|
||||||
|
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
|
||||||
|
*/
|
||||||
|
private fun exportAndShareAsImages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val exportResult = repository.exportPdf(documentationId)
|
||||||
|
val pdfFile = when (exportResult) {
|
||||||
|
is PdfExportResult.Success -> exportResult.file
|
||||||
|
is PdfExportResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
shareInProgress = false,
|
||||||
|
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
|
||||||
|
is PdfToImagesResult.Success -> _uiState.update {
|
||||||
|
it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PdfToImagesResult.Failure -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
shareInProgress = false,
|
||||||
|
shareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportiert die Dokumentation frisch als PDF und stellt sie zum Teilen bereit
|
||||||
|
* (Änderungswunsch: Format-Auswahl "PDF" statt "JPG") - ohne die JPEG-Konvertierung, die
|
||||||
|
* geteilte Datei ist die PDF selbst. Das eigentliche Teilen übernimmt der Screen anhand von
|
||||||
|
* [DocumentationEditUiState.pdfToShare] (s. [de.medapp.wunddoku.pdf.PdfSharer]).
|
||||||
|
*/
|
||||||
|
private fun exportAndSharePdf() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(shareInProgress = true, shareMessage = null, pdfToShare = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val exportResult = repository.exportPdf(documentationId)) {
|
||||||
|
is PdfExportResult.Success -> _uiState.update {
|
||||||
|
it.copy(shareInProgress = false, pdfToShare = exportResult.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PdfExportResult.Failure -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
shareInProgress = false,
|
||||||
|
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wird vom Screen aufgerufen, nachdem der "Senden an"-Dialog für [DocumentationEditUiState.imagesToShare]
|
||||||
|
* geöffnet wurde (erfolgreich oder nicht) - verhindert ein erneutes Öffnen bei der nächsten
|
||||||
|
* Recomposition, solange keine neuen Bilder erzeugt wurden. */
|
||||||
|
fun onImageShareHandled() {
|
||||||
|
_uiState.update { it.copy(imagesToShare = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */
|
||||||
|
fun onPdfShareHandled() {
|
||||||
|
_uiState.update { it.copy(pdfToShare = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissShareMessage() {
|
||||||
|
_uiState.update { it.copy(shareMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun PdfExportError.toUserMessage(): String = when (this) {
|
private fun PdfExportError.toUserMessage(): String = when (this) {
|
||||||
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
|
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
|
||||||
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
|
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
|
||||||
@@ -365,6 +514,10 @@ class DocumentationEditViewModel(
|
|||||||
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
|
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
|
||||||
repository.loadPhotoThumbnail(documentationId, fileName)
|
repository.loadPhotoThumbnail(documentationId, fileName)
|
||||||
|
|
||||||
|
/** Für die Vollbildansicht beim Antippen einer Foto-Kachel (Änderungswunsch). */
|
||||||
|
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) =
|
||||||
|
repository.loadPhotoFullSize(documentationId, fileName, maxWidthPx, maxHeightPx)
|
||||||
|
|
||||||
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
|
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
|
||||||
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
|
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
|
||||||
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
|
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.medapp.wunddoku.ui.documentation.edit
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import de.medapp.wunddoku.R
|
||||||
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollbild-Ansicht eines einzelnen Fotos in Originalgröße (Änderungswunsch: "Fotos in der App
|
||||||
|
* durch Antippen in Originalgröße angezeigt werden"). Lädt das Foto über [loadFullSize] separat
|
||||||
|
* von der kleinen Grid-Vorschau ([DocumentationEditViewModel.loadPhotoThumbnail]) - dieselbe
|
||||||
|
* Trennung wie bei den Signatur-Vorschauen: die kleine Kachel und die Vollbildansicht sind
|
||||||
|
* unabhängige Ladevorgänge mit unterschiedlicher Zielauflösung.
|
||||||
|
*
|
||||||
|
* "Originalgröße" bedeutet hier: so groß wie der Bildschirm es zulässt, ohne unnötig über die
|
||||||
|
* tatsächliche Displayauflösung hinaus zu decodieren (s. Kommentar an
|
||||||
|
* [de.medapp.wunddoku.data.repository.DocumentationRepository.loadPhotoFullSize]) - eine echte
|
||||||
|
* 1:1-Pixel-Darstellung würde bei modernen Fotoauflösungen ohnehin über die Bildschirmgröße
|
||||||
|
* hinausgehen und wäre nur per Zoom/Scroll sichtbar, was hier nicht gefordert ist.
|
||||||
|
*
|
||||||
|
* Volle Bildschirmbreite/-höhe via [DialogProperties.usePlatformDefaultWidth] = false, da der
|
||||||
|
* Standard-Dialog-Rahmen von Compose sonst deutlich schmaler als der Bildschirm wäre. Schließen
|
||||||
|
* über den Schließen-Button ODER Antippen des dunklen Hintergrunds.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PhotoViewerDialog(
|
||||||
|
photo: Photo,
|
||||||
|
loadFullSize: suspend (documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) -> Bitmap?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var loadFailed by remember(photo.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
LaunchedEffect(photo.id) {
|
||||||
|
val maxWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
|
||||||
|
val maxHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() }
|
||||||
|
val result = runCatching {
|
||||||
|
loadFullSize(photo.documentationId, photo.fileName, maxWidthPx, maxHeightPx)
|
||||||
|
}.getOrNull()
|
||||||
|
bitmap = result
|
||||||
|
loadFailed = result == null
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.clickable(onClick = onDismiss),
|
||||||
|
) {
|
||||||
|
val currentBitmap = bitmap
|
||||||
|
when {
|
||||||
|
currentBitmap != null -> Image(
|
||||||
|
bitmap = currentBitmap.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
|
||||||
|
loadFailed -> Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.BrokenImage,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.Center).size(48.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.documentation_edit_photo_load_failed),
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_cancel), tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,14 +32,14 @@
|
|||||||
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
|
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
|
||||||
<string name="patient_form_save">Speichern</string>
|
<string name="patient_form_save">Speichern</string>
|
||||||
|
|
||||||
<string name="documentation_edit_position">%1$d / %2$d</string>
|
<string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
|
||||||
<string name="documentation_edit_previous">Zurück</string>
|
<string name="documentation_edit_block_edited">Block bearbeitet</string>
|
||||||
<string name="documentation_edit_next">Weiter</string>
|
|
||||||
<string name="documentation_edit_free_text_label">Freitext</string>
|
<string name="documentation_edit_free_text_label">Freitext</string>
|
||||||
|
|
||||||
<string name="documentation_edit_photos_title">Fotos</string>
|
<string name="documentation_edit_photos_title">Fotos</string>
|
||||||
<string name="documentation_edit_add_photos">Hinzufügen</string>
|
<string name="documentation_edit_add_photos">Hinzufügen</string>
|
||||||
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
|
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
|
||||||
|
<string name="documentation_edit_photo_load_failed">Foto konnte nicht geladen werden.</string>
|
||||||
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
|
<string name="documentation_edit_delete_photo_confirm_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>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<files-path name="pdf_exports" path="pdf/" />
|
<files-path name="pdf_exports" path="pdf/" />
|
||||||
|
<files-path name="pdf_images" path="pdf_images/" />
|
||||||
<files-path name="photos" path="photos/" />
|
<files-path name="photos" path="photos/" />
|
||||||
<files-path name="signatures" path="signatures/" />
|
<files-path name="signatures" path="signatures/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
Reference in New Issue
Block a user