Merge pull request 'Beta 1.0 Release' (#11) from dev into main
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
+1
-1
@@ -25,7 +25,7 @@ local.properties
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Android profiling
|
# Android proFiling
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
# Signing keys
|
# Signing keys
|
||||||
|
|||||||
@@ -153,4 +153,4 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,579 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "1147eb9a721f44dcadce9449771d9766",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "patients",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "firstName",
|
||||||
|
"columnName": "firstName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastName",
|
||||||
|
"columnName": "lastName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "birthDate",
|
||||||
|
"columnName": "birthDate",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "street",
|
||||||
|
"columnName": "street",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "postalCode",
|
||||||
|
"columnName": "postalCode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "city",
|
||||||
|
"columnName": "city",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone1",
|
||||||
|
"columnName": "phone1",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone2",
|
||||||
|
"columnName": "phone2",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone3",
|
||||||
|
"columnName": "phone3",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_patients_lastName_firstName",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"lastName",
|
||||||
|
"firstName"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_patients_birthDate",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"birthDate"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "documentations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientId",
|
||||||
|
"columnName": "patientId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "continuedFromDocumentationId",
|
||||||
|
"columnName": "continuedFromDocumentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pdfLastGeneratedAt",
|
||||||
|
"columnName": "pdfLastGeneratedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "catalogVersion",
|
||||||
|
"columnName": "catalogVersion",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientFirstNameSnapshot",
|
||||||
|
"columnName": "patientFirstNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientLastNameSnapshot",
|
||||||
|
"columnName": "patientLastNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientBirthDateSnapshot",
|
||||||
|
"columnName": "patientBirthDateSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientStreetSnapshot",
|
||||||
|
"columnName": "patientStreetSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientPostalCodeSnapshot",
|
||||||
|
"columnName": "patientPostalCodeSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientCitySnapshot",
|
||||||
|
"columnName": "patientCitySnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_documentations_patientId_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"patientId",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_continuedFromDocumentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "patients",
|
||||||
|
"onDelete": "RESTRICT",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"patientId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "block_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockDefinitionId",
|
||||||
|
"columnName": "blockDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "titleSnapshot",
|
||||||
|
"columnName": "titleSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockOrder",
|
||||||
|
"columnName": "blockOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId_blockDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"blockDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "check_item_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockStateId",
|
||||||
|
"columnName": "blockStateId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checkItemDefinitionId",
|
||||||
|
"columnName": "checkItemDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "labelSnapshot",
|
||||||
|
"columnName": "labelSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemOrder",
|
||||||
|
"columnName": "itemOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checked",
|
||||||
|
"columnName": "checked",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFreeText",
|
||||||
|
"columnName": "isFreeText",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "freeTextValue",
|
||||||
|
"columnName": "freeTextValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId",
|
||||||
|
"checkItemDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "block_states",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "photos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_photos_documentationId_orderIndex",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"orderIndex"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "signatures",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT, `fileName` TEXT 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": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_signatures_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1147eb9a721f44dcadce9449771d9766')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "cd000019287c8c50d6b8d1b239aa12f0",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "patients",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "firstName",
|
||||||
|
"columnName": "firstName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastName",
|
||||||
|
"columnName": "lastName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "birthDate",
|
||||||
|
"columnName": "birthDate",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "street",
|
||||||
|
"columnName": "street",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "postalCode",
|
||||||
|
"columnName": "postalCode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "city",
|
||||||
|
"columnName": "city",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone1",
|
||||||
|
"columnName": "phone1",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone2",
|
||||||
|
"columnName": "phone2",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone3",
|
||||||
|
"columnName": "phone3",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_patients_lastName_firstName",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"lastName",
|
||||||
|
"firstName"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_patients_birthDate",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"birthDate"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "documentations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientId",
|
||||||
|
"columnName": "patientId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "continuedFromDocumentationId",
|
||||||
|
"columnName": "continuedFromDocumentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pdfLastGeneratedAt",
|
||||||
|
"columnName": "pdfLastGeneratedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "catalogVersion",
|
||||||
|
"columnName": "catalogVersion",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientFirstNameSnapshot",
|
||||||
|
"columnName": "patientFirstNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientLastNameSnapshot",
|
||||||
|
"columnName": "patientLastNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientBirthDateSnapshot",
|
||||||
|
"columnName": "patientBirthDateSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientStreetSnapshot",
|
||||||
|
"columnName": "patientStreetSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientPostalCodeSnapshot",
|
||||||
|
"columnName": "patientPostalCodeSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientCitySnapshot",
|
||||||
|
"columnName": "patientCitySnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_documentations_patientId_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"patientId",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_continuedFromDocumentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "patients",
|
||||||
|
"onDelete": "RESTRICT",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"patientId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "block_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockDefinitionId",
|
||||||
|
"columnName": "blockDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "titleSnapshot",
|
||||||
|
"columnName": "titleSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockOrder",
|
||||||
|
"columnName": "blockOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId_blockDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"blockDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "check_item_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockStateId",
|
||||||
|
"columnName": "blockStateId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checkItemDefinitionId",
|
||||||
|
"columnName": "checkItemDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "labelSnapshot",
|
||||||
|
"columnName": "labelSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemOrder",
|
||||||
|
"columnName": "itemOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checked",
|
||||||
|
"columnName": "checked",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFreeText",
|
||||||
|
"columnName": "isFreeText",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "freeTextValue",
|
||||||
|
"columnName": "freeTextValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId",
|
||||||
|
"checkItemDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "block_states",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "photos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_photos_documentationId_orderIndex",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"orderIndex"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "signatures",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signerName",
|
||||||
|
"columnName": "signerName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_signatures_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd000019287c8c50d6b8d1b239aa12f0')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 4,
|
||||||
|
"identityHash": "182fcff50c0191c9414187dd7b7cacd2",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "patients",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "firstName",
|
||||||
|
"columnName": "firstName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastName",
|
||||||
|
"columnName": "lastName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "birthDate",
|
||||||
|
"columnName": "birthDate",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "street",
|
||||||
|
"columnName": "street",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "postalCode",
|
||||||
|
"columnName": "postalCode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "city",
|
||||||
|
"columnName": "city",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone1",
|
||||||
|
"columnName": "phone1",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone2",
|
||||||
|
"columnName": "phone2",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone3",
|
||||||
|
"columnName": "phone3",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_patients_lastName_firstName",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"lastName",
|
||||||
|
"firstName"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_patients_birthDate",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"birthDate"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "documentations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientId",
|
||||||
|
"columnName": "patientId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "continuedFromDocumentationId",
|
||||||
|
"columnName": "continuedFromDocumentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pdfLastGeneratedAt",
|
||||||
|
"columnName": "pdfLastGeneratedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "catalogVersion",
|
||||||
|
"columnName": "catalogVersion",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientFirstNameSnapshot",
|
||||||
|
"columnName": "patientFirstNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientLastNameSnapshot",
|
||||||
|
"columnName": "patientLastNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientBirthDateSnapshot",
|
||||||
|
"columnName": "patientBirthDateSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientStreetSnapshot",
|
||||||
|
"columnName": "patientStreetSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientPostalCodeSnapshot",
|
||||||
|
"columnName": "patientPostalCodeSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientCitySnapshot",
|
||||||
|
"columnName": "patientCitySnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_documentations_patientId_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"patientId",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_continuedFromDocumentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "patients",
|
||||||
|
"onDelete": "RESTRICT",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"patientId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "block_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockDefinitionId",
|
||||||
|
"columnName": "blockDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "titleSnapshot",
|
||||||
|
"columnName": "titleSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockOrder",
|
||||||
|
"columnName": "blockOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId_blockDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"blockDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "check_item_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockStateId",
|
||||||
|
"columnName": "blockStateId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checkItemDefinitionId",
|
||||||
|
"columnName": "checkItemDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "labelSnapshot",
|
||||||
|
"columnName": "labelSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemOrder",
|
||||||
|
"columnName": "itemOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checked",
|
||||||
|
"columnName": "checked",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFreeText",
|
||||||
|
"columnName": "isFreeText",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "freeTextValue",
|
||||||
|
"columnName": "freeTextValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId",
|
||||||
|
"checkItemDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "block_states",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "photos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_photos_documentationId_orderIndex",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"orderIndex"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "signatures",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signerName",
|
||||||
|
"columnName": "signerName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_signatures_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "wound_carers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signatureFileName",
|
||||||
|
"columnName": "signatureFileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '182fcff50c0191c9414187dd7b7cacd2')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 5,
|
||||||
|
"identityHash": "12448571f5ffcf00398fa1d8260c9b2b",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "patients",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "firstName",
|
||||||
|
"columnName": "firstName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastName",
|
||||||
|
"columnName": "lastName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "birthDate",
|
||||||
|
"columnName": "birthDate",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "street",
|
||||||
|
"columnName": "street",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "postalCode",
|
||||||
|
"columnName": "postalCode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "city",
|
||||||
|
"columnName": "city",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone1",
|
||||||
|
"columnName": "phone1",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone2",
|
||||||
|
"columnName": "phone2",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone3",
|
||||||
|
"columnName": "phone3",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_patients_lastName_firstName",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"lastName",
|
||||||
|
"firstName"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_patients_birthDate",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"birthDate"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "documentations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientId",
|
||||||
|
"columnName": "patientId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "continuedFromDocumentationId",
|
||||||
|
"columnName": "continuedFromDocumentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pdfLastGeneratedAt",
|
||||||
|
"columnName": "pdfLastGeneratedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "catalogVersion",
|
||||||
|
"columnName": "catalogVersion",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientFirstNameSnapshot",
|
||||||
|
"columnName": "patientFirstNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientLastNameSnapshot",
|
||||||
|
"columnName": "patientLastNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientBirthDateSnapshot",
|
||||||
|
"columnName": "patientBirthDateSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientStreetSnapshot",
|
||||||
|
"columnName": "patientStreetSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientPostalCodeSnapshot",
|
||||||
|
"columnName": "patientPostalCodeSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientCitySnapshot",
|
||||||
|
"columnName": "patientCitySnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_documentations_patientId_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"patientId",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_continuedFromDocumentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "patients",
|
||||||
|
"onDelete": "RESTRICT",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"patientId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "block_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockDefinitionId",
|
||||||
|
"columnName": "blockDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "titleSnapshot",
|
||||||
|
"columnName": "titleSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockOrder",
|
||||||
|
"columnName": "blockOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId_blockDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"blockDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "check_item_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockStateId",
|
||||||
|
"columnName": "blockStateId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checkItemDefinitionId",
|
||||||
|
"columnName": "checkItemDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "labelSnapshot",
|
||||||
|
"columnName": "labelSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemOrder",
|
||||||
|
"columnName": "itemOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checked",
|
||||||
|
"columnName": "checked",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFreeText",
|
||||||
|
"columnName": "isFreeText",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "freeTextValue",
|
||||||
|
"columnName": "freeTextValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isStaticHint",
|
||||||
|
"columnName": "isStaticHint",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId",
|
||||||
|
"checkItemDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "block_states",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "photos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_photos_documentationId_orderIndex",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"orderIndex"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "signatures",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signerName",
|
||||||
|
"columnName": "signerName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_signatures_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "wound_carers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signatureFileName",
|
||||||
|
"columnName": "signatureFileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12448571f5ffcf00398fa1d8260c9b2b')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 6,
|
||||||
|
"identityHash": "5e5d96efeef00d1ff7d404166a900974",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "patients",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "firstName",
|
||||||
|
"columnName": "firstName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastName",
|
||||||
|
"columnName": "lastName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "birthDate",
|
||||||
|
"columnName": "birthDate",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "street",
|
||||||
|
"columnName": "street",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "postalCode",
|
||||||
|
"columnName": "postalCode",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "city",
|
||||||
|
"columnName": "city",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone1",
|
||||||
|
"columnName": "phone1",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone2",
|
||||||
|
"columnName": "phone2",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "phone3",
|
||||||
|
"columnName": "phone3",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_patients_lastName_firstName",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"lastName",
|
||||||
|
"firstName"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_patients_birthDate",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"birthDate"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "documentations",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientId",
|
||||||
|
"columnName": "patientId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "continuedFromDocumentationId",
|
||||||
|
"columnName": "continuedFromDocumentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "pdfLastGeneratedAt",
|
||||||
|
"columnName": "pdfLastGeneratedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "catalogVersion",
|
||||||
|
"columnName": "catalogVersion",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientFirstNameSnapshot",
|
||||||
|
"columnName": "patientFirstNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientLastNameSnapshot",
|
||||||
|
"columnName": "patientLastNameSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientBirthDateSnapshot",
|
||||||
|
"columnName": "patientBirthDateSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientStreetSnapshot",
|
||||||
|
"columnName": "patientStreetSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientPostalCodeSnapshot",
|
||||||
|
"columnName": "patientPostalCodeSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "patientCitySnapshot",
|
||||||
|
"columnName": "patientCitySnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_documentations_patientId_createdAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"patientId",
|
||||||
|
"createdAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_status",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_documentations_continuedFromDocumentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "patients",
|
||||||
|
"onDelete": "RESTRICT",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"patientId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"continuedFromDocumentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "block_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockDefinitionId",
|
||||||
|
"columnName": "blockDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "titleSnapshot",
|
||||||
|
"columnName": "titleSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockOrder",
|
||||||
|
"columnName": "blockOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_block_states_documentationId_blockDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"blockDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "check_item_states",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, `inputSpec` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "blockStateId",
|
||||||
|
"columnName": "blockStateId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checkItemDefinitionId",
|
||||||
|
"columnName": "checkItemDefinitionId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "labelSnapshot",
|
||||||
|
"columnName": "labelSnapshot",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemOrder",
|
||||||
|
"columnName": "itemOrder",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "checked",
|
||||||
|
"columnName": "checked",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFreeText",
|
||||||
|
"columnName": "isFreeText",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "freeTextValue",
|
||||||
|
"columnName": "freeTextValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isStaticHint",
|
||||||
|
"columnName": "isStaticHint",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "inputSpec",
|
||||||
|
"columnName": "inputSpec",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"blockStateId",
|
||||||
|
"checkItemDefinitionId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "block_states",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"blockStateId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "photos",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_photos_documentationId_orderIndex",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId",
|
||||||
|
"orderIndex"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "signatures",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "documentationId",
|
||||||
|
"columnName": "documentationId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signerName",
|
||||||
|
"columnName": "signerName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileName",
|
||||||
|
"columnName": "fileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "orderIndex",
|
||||||
|
"columnName": "orderIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_signatures_documentationId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "documentations",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"documentationId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "wound_carers",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signatureFileName",
|
||||||
|
"columnName": "signatureFileName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5e5d96efeef00d1ff7d404166a900974')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,7 @@ import de.medapp.wunddoku.data.entity.BlockStateEntity
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bewusst schlanke DAO: Nur Datenbankzugriffe, keine Koordination mehrerer Tabellen (das
|
* Bewusst schlanke DAO: Nur Datenbankzugriffe, keine Koordination mehrerer Tabellen (das
|
||||||
* übernimmt DocumentationRepository). Kein `update` für ganze Entities nötig, da nur
|
* übernimmt DocumentationRepository).
|
||||||
* `freeText`/`updatedAt` sich nach dem Erstellen je ändern (Autosave) – ein gezieltes
|
|
||||||
* Teil-Update ist hier klarer als ein vollständiges Entity-Update.
|
|
||||||
*/
|
*/
|
||||||
@Dao
|
@Dao
|
||||||
interface BlockStateDao {
|
interface BlockStateDao {
|
||||||
@@ -22,7 +20,4 @@ interface BlockStateDao {
|
|||||||
|
|
||||||
@Query("SELECT * FROM block_states WHERE documentationId = :documentationId ORDER BY blockOrder")
|
@Query("SELECT * FROM block_states WHERE documentationId = :documentationId ORDER BY blockOrder")
|
||||||
suspend fun getForDocumentation(documentationId: String): List<BlockStateEntity>
|
suspend fun getForDocumentation(documentationId: String): List<BlockStateEntity>
|
||||||
|
|
||||||
@Query("UPDATE block_states SET freeText = :freeText, updatedAt = :updatedAt WHERE id = :id")
|
|
||||||
suspend fun updateFreeText(id: String, freeText: String, updatedAt: Long)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,8 @@ interface CheckItemStateDao {
|
|||||||
|
|
||||||
@Query("UPDATE check_item_states SET checked = :checked WHERE id = :id")
|
@Query("UPDATE check_item_states SET checked = :checked WHERE id = :id")
|
||||||
suspend fun setChecked(id: String, checked: Boolean)
|
suspend fun setChecked(id: String, checked: Boolean)
|
||||||
|
|
||||||
|
/** Speichert den Freitext der Freitext-Option (isFreeText = true) eines Blocks. */
|
||||||
|
@Query("UPDATE check_item_states SET freeTextValue = :value WHERE id = :id")
|
||||||
|
suspend fun updateFreeTextValue(id: String, value: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import androidx.room.Transaction
|
|||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
||||||
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
|
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
|
||||||
import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -27,15 +26,22 @@ interface DocumentationDao {
|
|||||||
suspend fun getById(id: String): DocumentationEntity?
|
suspend fun getById(id: String): DocumentationEntity?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen mit
|
* Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen.
|
||||||
* Patientenbezug, ohne die vollständigen Blöcke zu laden – bewusst schlank für Performance
|
* Liefert direkt [DocumentationEntity] statt einer Relation mit Patient - Name/Adresse für
|
||||||
* bei größeren Datenmengen.
|
* die Anzeige stehen bereits als Snapshot auf der Dokumentation selbst (siehe
|
||||||
|
* DocumentationEntity.patientFirstNameSnapshot etc.), ein JOIN mit der Patient-Tabelle ist
|
||||||
|
* für die reine Listenanzeige nicht mehr nötig.
|
||||||
*/
|
*/
|
||||||
@Transaction
|
|
||||||
@Query("SELECT * FROM documentations ORDER BY createdAt DESC")
|
@Query("SELECT * FROM documentations ORDER BY createdAt DESC")
|
||||||
fun observeAllWithPatient(): Flow<List<DocumentationWithPatient>>
|
fun observeAll(): Flow<List<DocumentationEntity>>
|
||||||
|
|
||||||
@Transaction
|
/**
|
||||||
|
* Sucht bewusst weiterhin gegen den AKTUELLEN Patientennamen (JOIN mit patients), nicht
|
||||||
|
* gegen den eingefrorenen Namens-Snapshot der jeweiligen Dokumentation: Ein Nutzer sucht
|
||||||
|
* "nach dem Patienten, den ich kenne" (aktueller, echter Name), nicht nach einem
|
||||||
|
* möglicherweise veralteten historischen Namen. Die zurückgegebenen Zeilen selbst zeigen
|
||||||
|
* dann trotzdem den Snapshot-Namen der jeweiligen Dokumentation an (siehe toSummary()).
|
||||||
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT d.* FROM documentations d
|
SELECT d.* FROM documentations d
|
||||||
@@ -46,7 +52,7 @@ interface DocumentationDao {
|
|||||||
ORDER BY d.createdAt DESC
|
ORDER BY d.createdAt DESC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun search(query: String): Flow<List<DocumentationWithPatient>>
|
fun search(query: String): Flow<List<DocumentationEntity>>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM documentations WHERE id = :id")
|
@Query("SELECT * FROM documentations WHERE id = :id")
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ interface SignatureDao {
|
|||||||
@Query("SELECT * FROM signatures WHERE id = :id")
|
@Query("SELECT * FROM signatures WHERE id = :id")
|
||||||
suspend fun getById(id: String): SignatureEntity?
|
suspend fun getById(id: String): SignatureEntity?
|
||||||
|
|
||||||
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId")
|
/** Bereits stabil nach [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex] sortiert
|
||||||
|
* - dieselbe Reihenfolge, in der Signaturen später auch im PDF erscheinen (PDF-003). */
|
||||||
|
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId ORDER BY orderIndex")
|
||||||
suspend fun getForDocumentation(documentationId: String): List<SignatureEntity>
|
suspend fun getForDocumentation(documentationId: String): List<SignatureEntity>
|
||||||
|
|
||||||
|
/** Für die Vergabe des nächsten [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex]
|
||||||
|
* beim Anlegen einer neuen Signatur - exakt analog [PhotoDao.getMaxOrderIndex]. */
|
||||||
|
@Query("SELECT COALESCE(MAX(orderIndex), -1) FROM signatures WHERE documentationId = :documentationId")
|
||||||
|
suspend fun getMaxOrderIndex(documentationId: String): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.medapp.wunddoku.data.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import de.medapp.wunddoku.data.entity.WoundCarerEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface WoundCarerDao {
|
||||||
|
|
||||||
|
/** Ersetzt einen vorhandenen Eintrag mit derselben [WoundCarerEntity.id] vollständig (Upsert
|
||||||
|
* per Primärschlüssel) - siehe [de.medapp.wunddoku.data.repository.WoundCarerRepository]
|
||||||
|
* (Find-by-Name + Wiederverwendung der gefundenen ID) für die eigentliche
|
||||||
|
* "Neuanlegen-oder-Aktualisieren"-Logik anhand des Namens. */
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertOrUpdate(entity: WoundCarerEntity)
|
||||||
|
|
||||||
|
/** Case-insensitiver Exakt-Abgleich - entscheidet, ob unter diesem (getrimmten) Namen
|
||||||
|
* bereits ein Wundversorger bekannt ist (dann: Signatur aktualisieren) oder ein neuer Eintrag
|
||||||
|
* angelegt werden muss. */
|
||||||
|
@Query("SELECT * FROM wound_carers WHERE name = :name COLLATE NOCASE LIMIT 1")
|
||||||
|
suspend fun findByName(name: String): WoundCarerEntity?
|
||||||
|
|
||||||
|
/** Namensvorschläge nach den ersten eingegebenen Buchstaben (Präfix, case-insensitiv),
|
||||||
|
* neueste Verwendung zuerst. */
|
||||||
|
@Query("SELECT * FROM wound_carers WHERE name LIKE :prefix || '%' COLLATE NOCASE ORDER BY updatedAt DESC LIMIT :limit")
|
||||||
|
suspend fun searchByNamePrefix(prefix: String, limit: Int): List<WoundCarerEntity>
|
||||||
|
|
||||||
|
/** Für die Vorbelegung des Signatur-Dialogs mit dem zuletzt verwendeten Wundversorger. */
|
||||||
|
@Query("SELECT * FROM wound_carers ORDER BY updatedAt DESC LIMIT 1")
|
||||||
|
suspend fun getMostRecentlyUsed(): WoundCarerEntity?
|
||||||
|
}
|
||||||
@@ -11,18 +11,48 @@ import de.medapp.wunddoku.data.dao.DocumentationDao
|
|||||||
import de.medapp.wunddoku.data.dao.PatientDao
|
import de.medapp.wunddoku.data.dao.PatientDao
|
||||||
import de.medapp.wunddoku.data.dao.PhotoDao
|
import de.medapp.wunddoku.data.dao.PhotoDao
|
||||||
import de.medapp.wunddoku.data.dao.SignatureDao
|
import de.medapp.wunddoku.data.dao.SignatureDao
|
||||||
|
import de.medapp.wunddoku.data.dao.WoundCarerDao
|
||||||
import de.medapp.wunddoku.data.entity.BlockStateEntity
|
import de.medapp.wunddoku.data.entity.BlockStateEntity
|
||||||
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
||||||
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
||||||
import de.medapp.wunddoku.data.entity.PatientEntity
|
import de.medapp.wunddoku.data.entity.PatientEntity
|
||||||
import de.medapp.wunddoku.data.entity.PhotoEntity
|
import de.medapp.wunddoku.data.entity.PhotoEntity
|
||||||
import de.medapp.wunddoku.data.entity.SignatureEntity
|
import de.medapp.wunddoku.data.entity.SignatureEntity
|
||||||
|
import de.medapp.wunddoku.data.entity.WoundCarerEntity
|
||||||
import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
|
import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zentrale Room-Datenbank. `exportSchema = true`, damit Room die Schema-Historie als JSON in
|
* Zentrale Room-Datenbank. `exportSchema = true`, damit Room die Schema-Historie als JSON in
|
||||||
* app/schemas/ ablegt (im Gradle-Skript über `room { schemaDirectory(...) }` zu konfigurieren,
|
* app/schemas/ ablegt – Grundlage für nachvollziehbare Migrationen.
|
||||||
* s. Kommentar unten) – das ist die Grundlage für spätere, nachvollziehbare Migrationen.
|
*
|
||||||
|
* Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung -
|
||||||
|
* Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von
|
||||||
|
* CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf
|
||||||
|
* DocumentationEntity. Dieser Sprung nutzte noch `fallbackToDestructiveMigration()` (die App
|
||||||
|
* befand sich vor dem produktiven Einsatz, keine echten Patientendaten zu migrieren).
|
||||||
|
*
|
||||||
|
* Version 3 (vorher 2, MEDIA-004/PDF-003): `signatures.orderIndex` neu (stabile Reihenfolge
|
||||||
|
* mehrerer Signaturen je Dokumentation) sowie `signatures.signerName` von NULLABLE auf NOT NULL.
|
||||||
|
* Anders als beim Sprung 1->2 wird HIER eine echte, verlustfreie [Migration] verwendet
|
||||||
|
* ([MIGRATION_2_3]) - reale Installationen können ab jetzt bereits Patienten-/
|
||||||
|
* Dokumentationsdaten enthalten, die nicht stillschweigend durch einen destruktiven Fallback
|
||||||
|
* verloren gehen dürfen (Aufgabenstellung Abschnitt 23). Der bisherige
|
||||||
|
* `fallbackToDestructiveMigration()`-Aufruf wurde deshalb entfernt; fehlt für einen künftigen
|
||||||
|
* Versionssprung eine passende Migration, bricht Room den Aufbau der Datenbank kontrolliert mit
|
||||||
|
* einer Exception ab, statt Daten unbemerkt zu löschen.
|
||||||
|
*
|
||||||
|
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
|
||||||
|
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
|
||||||
|
* additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen.
|
||||||
|
*
|
||||||
|
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
|
||||||
|
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
|
||||||
|
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
|
||||||
|
*
|
||||||
|
* Version 6 (vorher 5, Änderungswunsch): `check_item_states.inputSpec` neu (strukturierte
|
||||||
|
* Eingabefelder wie Zahl/Auswahlliste statt Freitext, s. [de.medapp.wunddoku.domain.model.InputSpec])
|
||||||
|
* - ebenfalls rein additive Spalte mit Default-Wert ([MIGRATION_5_6]), keine bestehenden Daten
|
||||||
|
* verändert.
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -32,8 +62,9 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
|
|||||||
CheckItemStateEntity::class,
|
CheckItemStateEntity::class,
|
||||||
PhotoEntity::class,
|
PhotoEntity::class,
|
||||||
SignatureEntity::class,
|
SignatureEntity::class,
|
||||||
|
WoundCarerEntity::class,
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 6,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(DocumentationStatusConverter::class)
|
@TypeConverters(DocumentationStatusConverter::class)
|
||||||
@@ -45,6 +76,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun checkItemStateDao(): CheckItemStateDao
|
abstract fun checkItemStateDao(): CheckItemStateDao
|
||||||
abstract fun photoDao(): PhotoDao
|
abstract fun photoDao(): PhotoDao
|
||||||
abstract fun signatureDao(): SignatureDao
|
abstract fun signatureDao(): SignatureDao
|
||||||
|
abstract fun woundCarerDao(): WoundCarerDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DATABASE_NAME = "wunddoku.db"
|
private const val DATABASE_NAME = "wunddoku.db"
|
||||||
@@ -58,7 +90,10 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
DATABASE_NAME,
|
DATABASE_NAME,
|
||||||
).build().also { instance = it }
|
)
|
||||||
|
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
|
||||||
|
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
||||||
|
.build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.medapp.wunddoku.data.database
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration von Schema-Version 2 auf 3 (MEDIA-004/PDF-003: Mehrere Signaturen pro
|
||||||
|
* Dokumentation).
|
||||||
|
*
|
||||||
|
* Zwei Änderungen am `signatures`-Schema, die NICHT über ein einfaches `ALTER TABLE ... ADD
|
||||||
|
* COLUMN` abgebildet werden können:
|
||||||
|
*
|
||||||
|
* 1. Neue Spalte `orderIndex INTEGER NOT NULL` für eine stabile Reihenfolge mehrerer
|
||||||
|
* Signaturen (bisher gab es nur eine implizite, undefinierte Reihenfolge).
|
||||||
|
* 2. `signerName` wechselt von NULLABLE auf NOT NULL. SQLite kann eine bestehende Spalte nicht
|
||||||
|
* nachträglich auf NOT NULL umstellen - deshalb wird die Tabelle komplett neu aufgebaut
|
||||||
|
* (neue Tabelle mit Zielschema anlegen, Daten verlustfrei übernehmen, alte Tabelle
|
||||||
|
* entfernen, neue Tabelle umbenennen, Index wiederherstellen).
|
||||||
|
*
|
||||||
|
* Es wird NICHT angenommen, dass die Tabelle leer ist, nur weil die Signaturfunktion vor
|
||||||
|
* dieser Version über keine UI erreichbar war (Aufgabenstellung Abschnitt 22) - die Migration
|
||||||
|
* behandelt den allgemeinen Fall mit ggf. bereits vorhandenen Zeilen:
|
||||||
|
*
|
||||||
|
* - `orderIndex` wird pro `documentationId` anhand von `createdAt` (mit `id` als
|
||||||
|
* Tie-Breaker für den unwahrscheinlichen Fall exakt gleicher Zeitstempel) rückwirkend
|
||||||
|
* vergeben, beginnend bei 0 - über eine korrelierte Unterabfrage, da SQLite in der von Room
|
||||||
|
* unterstützten Form kein `ROW_NUMBER()`-Fenster in einem `UPDATE` bereitstellt.
|
||||||
|
* - `signerName` wird für Altzeilen mit NULL **oder** leerem/nur-Leerzeichen-Wert auf den klar
|
||||||
|
* als historisch fehlend erkennbaren technischen Platzhalter `"(nicht erfasst)"` gesetzt
|
||||||
|
* (ausdrücklich freigegeben - NICHT `""`, NICHT `"Unbekannt"`, da Letzteres wie ein echter
|
||||||
|
* Name wirken könnte). Der leere-String-Fall wurde defensiv ergänzt: `signerName` war in
|
||||||
|
* Schema 2 zwar nullable, aber nie auf "nicht blank" geprüft - ein rein theoretisch über
|
||||||
|
* einen früheren Code-Pfad denkbarer Leerstring soll denselben Platzhalter erhalten wie
|
||||||
|
* NULL, damit hier keine Zeile mit einem leeren, aber technisch "vorhandenen" Namen entsteht.
|
||||||
|
* Diese Regel gilt ausschließlich für migrierte Altbestände - neu erfasste Signaturen
|
||||||
|
* erzwingen weiterhin serverseitig (Repository) ein nicht-leeres, getrimmtes Pflichtfeld.
|
||||||
|
*
|
||||||
|
* Da die Signaturfunktion vor dieser Version über keine UI erreichbar war, ist in der Praxis auf
|
||||||
|
* so gut wie jeder realen Installation ohnehin mit einer leeren `signatures`-Tabelle zu rechnen
|
||||||
|
* - die obige Logik greift dann einfach nicht (INSERT ... SELECT liefert 0 Zeilen).
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `signatures_new` (
|
||||||
|
`id` TEXT NOT NULL,
|
||||||
|
`documentationId` TEXT NOT NULL,
|
||||||
|
`signerName` TEXT NOT NULL,
|
||||||
|
`fileName` TEXT NOT NULL,
|
||||||
|
`orderIndex` INTEGER NOT NULL,
|
||||||
|
`createdAt` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`),
|
||||||
|
FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
INSERT INTO `signatures_new` (`id`, `documentationId`, `signerName`, `fileName`, `orderIndex`, `createdAt`)
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.documentationId,
|
||||||
|
CASE
|
||||||
|
WHEN s.signerName IS NULL OR TRIM(s.signerName) = '' THEN '(nicht erfasst)'
|
||||||
|
ELSE s.signerName
|
||||||
|
END,
|
||||||
|
s.fileName,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM signatures s2
|
||||||
|
WHERE s2.documentationId = s.documentationId
|
||||||
|
AND (
|
||||||
|
s2.createdAt < s.createdAt
|
||||||
|
OR (s2.createdAt = s.createdAt AND s2.id < s.id)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
s.createdAt
|
||||||
|
FROM signatures s
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execSQL("DROP TABLE `signatures`")
|
||||||
|
db.execSQL("ALTER TABLE `signatures_new` RENAME TO `signatures`")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `signatures` (`documentationId`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration von Schema-Version 3 auf 4 (Änderungswunsch: Wundversorger-Register für
|
||||||
|
* Namensvorschläge und Standard-Vorbelegung im Signatur-Dialog).
|
||||||
|
*
|
||||||
|
* Rein additiv: eine neue, komplett eigenständige Tabelle `wound_carers` ohne Fremdschlüssel-
|
||||||
|
* Bezug zu einer bestehenden Tabelle (unabhängig vom Lebenszyklus einzelner Dokumentationen/
|
||||||
|
* Signaturen, s. [de.medapp.wunddoku.data.repository.WoundCarerRepository]). Keine bestehenden
|
||||||
|
* Tabellen/Daten werden hierbei verändert.
|
||||||
|
*/
|
||||||
|
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `wound_carers` (
|
||||||
|
`id` TEXT NOT NULL,
|
||||||
|
`name` TEXT NOT NULL,
|
||||||
|
`signatureFileName` TEXT NOT NULL,
|
||||||
|
`updatedAt` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration von Schema-Version 4 auf 5 (finale fachliche Katalog-Inhalte: neuer Content-Typ
|
||||||
|
* "statischer Hinweistext", z. B. "+ bei Bedarf" bei "Empfohlene Wundversorgung").
|
||||||
|
*
|
||||||
|
* Rein additiv - eine einzelne neue, NOT NULL-Spalte mit Default `false` (0) auf einer
|
||||||
|
* bestehenden Tabelle. Anders als bei der Signaturen-Migration (2->3, dort war ein
|
||||||
|
* NULL->NOT-NULL-Wechsel auf einer bereits befüllten Spalte nötig) ist hier ein einfaches
|
||||||
|
* `ALTER TABLE ... ADD COLUMN` mit Default-Wert ausreichend und von SQLite direkt unterstützt -
|
||||||
|
* kein Tabellenneubau nötig. Bestehende Zeilen erhalten automatisch `isStaticHint = 0`
|
||||||
|
* (entspricht ihrem bisherigen, unveränderten Verhalten als normale Checkbox- bzw.
|
||||||
|
* Freitext-Zeile).
|
||||||
|
*/
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE `check_item_states` ADD COLUMN `isStaticHint` INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration von Schema-Version 5 auf 6 (Änderungswunsch: strukturierte Eingabefelder statt
|
||||||
|
* Freitext, z. B. "Länge/Breite/Tiefe" bei "Größe" oder "Von ... bis ... Uhr" bei "Fistel /
|
||||||
|
* Tasche" - s. [de.medapp.wunddoku.domain.model.InputSpec]).
|
||||||
|
*
|
||||||
|
* Wie schon bei Migration 4->5 rein additiv: eine neue NOT NULL-Spalte mit Default `''` (leerer
|
||||||
|
* String = "dieser Eintrag hat keine strukturierten Felder", entspricht dem bisherigen
|
||||||
|
* Verhalten aller bestehenden Zeilen unverändert). Kein Tabellenneubau nötig.
|
||||||
|
*/
|
||||||
|
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE `check_item_states` ADD COLUMN `inputSpec` TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,13 @@ import androidx.room.PrimaryKey
|
|||||||
* Foreign Key: Der Katalog ist keine Datenbanktabelle, sondern Kotlin-Code.
|
* Foreign Key: Der Katalog ist keine Datenbanktabelle, sondern Kotlin-Code.
|
||||||
*
|
*
|
||||||
* [titleSnapshot]/[blockOrder] speichern den zum Erstellungszeitpunkt der Dokumentation
|
* [titleSnapshot]/[blockOrder] speichern den zum Erstellungszeitpunkt der Dokumentation
|
||||||
* gültigen Titel bzw. die gültige Reihenfolge (Architektur-Dokument Abschnitt 2.4) – das
|
* gültigen Titel bzw. die gültige Reihenfolge - das stellt sicher, dass eine später geöffnete,
|
||||||
* stellt sicher, dass eine später geöffnete, historische Dokumentation unverändert korrekt
|
* historische Dokumentation unverändert korrekt dargestellt wird, selbst wenn sich der Katalog
|
||||||
* dargestellt wird, selbst wenn sich der Katalog danach ändert.
|
* danach ändert.
|
||||||
|
*
|
||||||
|
* Kein `freeText`-Feld mehr: Der Freitext eines Blocks wird seit der Umstellung auf eine
|
||||||
|
* Checkbox-Option als CheckItemStateEntity mit `isFreeText = true` geführt (siehe dort), nicht
|
||||||
|
* mehr als eigenes Feld am Block.
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "block_states",
|
tableName = "block_states",
|
||||||
@@ -40,6 +44,5 @@ data class BlockStateEntity(
|
|||||||
val blockDefinitionId: String,
|
val blockDefinitionId: String,
|
||||||
val titleSnapshot: String,
|
val titleSnapshot: String,
|
||||||
val blockOrder: Int,
|
val blockOrder: Int,
|
||||||
val freeText: String,
|
|
||||||
val updatedAt: Long,
|
val updatedAt: Long,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,32 @@ import androidx.room.PrimaryKey
|
|||||||
*
|
*
|
||||||
* [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche
|
* [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche
|
||||||
* Begründung, auf Checkbox-Ebene.
|
* Begründung, auf Checkbox-Ebene.
|
||||||
|
*
|
||||||
|
* [isFreeText]/[freeTextValue]: Jeder Block bekommt beim Erstellen automatisch genau eine
|
||||||
|
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
|
||||||
|
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
|
||||||
|
* 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]: 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.
|
||||||
|
*
|
||||||
|
* [inputSpec]: NEU. Nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens ein oder
|
||||||
|
* mehrere strukturierte Eingabefelder besitzt (Zahl oder Auswahlliste, z. B. "Von [3] bis [5]
|
||||||
|
* Uhr" oder "Länge/Breite/Tiefe"), s. [de.medapp.wunddoku.domain.model.InputSpec] und
|
||||||
|
* [de.medapp.wunddoku.domain.model.InputSpecCodec]. Die eingegebenen Werte selbst liegen -
|
||||||
|
* mehrere durch ein Trennzeichen kombiniert - weiterhin in [freeTextValue]; es wird also KEINE
|
||||||
|
* weitere Spalte pro Feld benötigt. [checked] wird bei einem solchen Eintrag automatisch von der
|
||||||
|
* UI gesetzt, sobald mindestens ein Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall
|
||||||
|
* sonst, keine zweite "bearbeitet"-Logik.
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "check_item_states",
|
tableName = "check_item_states",
|
||||||
@@ -33,4 +59,8 @@ data class CheckItemStateEntity(
|
|||||||
val labelSnapshot: String,
|
val labelSnapshot: String,
|
||||||
val itemOrder: Int,
|
val itemOrder: Int,
|
||||||
val checked: Boolean,
|
val checked: Boolean,
|
||||||
|
val isFreeText: Boolean = false,
|
||||||
|
val freeTextValue: String = "",
|
||||||
|
val isStaticHint: Boolean = false,
|
||||||
|
val inputSpec: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,4 +49,13 @@ data class DocumentationEntity(
|
|||||||
val continuedFromDocumentationId: String?,
|
val continuedFromDocumentationId: String?,
|
||||||
val pdfLastGeneratedAt: Long?,
|
val pdfLastGeneratedAt: Long?,
|
||||||
val catalogVersion: Int,
|
val catalogVersion: Int,
|
||||||
|
// Patienten-Stammdaten-Snapshot zum Erstellungszeitpunkt dieser Dokumentation - siehe
|
||||||
|
// ausführliche Begründung in domain/model/PatientStammdatenSnapshot.kt. Frisch befüllt bei
|
||||||
|
// createEmpty()/continueDocumentation() im DocumentationRepository, danach unveränderlich.
|
||||||
|
val patientFirstNameSnapshot: String,
|
||||||
|
val patientLastNameSnapshot: String,
|
||||||
|
val patientBirthDateSnapshot: String,
|
||||||
|
val patientStreetSnapshot: String,
|
||||||
|
val patientPostalCodeSnapshot: String,
|
||||||
|
val patientCitySnapshot: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import androidx.room.PrimaryKey
|
|||||||
* TypeConverter auf LocalDate: Der String ist bereits das native SQLite-Format, direkt
|
* TypeConverter auf LocalDate: Der String ist bereits das native SQLite-Format, direkt
|
||||||
* sortierbar und ohne Konverter lesbar. Der Mapper (PatientMapper) übernimmt die Umwandlung
|
* sortierbar und ohne Konverter lesbar. Der Mapper (PatientMapper) übernimmt die Umwandlung
|
||||||
* zu/von `java.time.LocalDate` beim Übergang zur Domain-Schicht.
|
* zu/von `java.time.LocalDate` beim Übergang zur Domain-Schicht.
|
||||||
|
* Die Adresse wird atomar gespeichert (street/postalCode/city) statt als einzelnes Freitextfeld
|
||||||
|
* - ermöglicht z. B. eine passende Tastatur für die PLZ-Eingabe und vermeidet, dass die
|
||||||
|
* Persistenzschicht auf unstrukturiertem Freitext basiert.
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "patients",
|
tableName = "patients",
|
||||||
@@ -26,7 +29,9 @@ data class PatientEntity(
|
|||||||
val firstName: String,
|
val firstName: String,
|
||||||
val lastName: String,
|
val lastName: String,
|
||||||
val birthDate: String,
|
val birthDate: String,
|
||||||
val address: String,
|
val street: String,
|
||||||
|
val postalCode: String,
|
||||||
|
val city: String,
|
||||||
val phone1: String,
|
val phone1: String,
|
||||||
val phone2: String?,
|
val phone2: String?,
|
||||||
val phone3: String?,
|
val phone3: String?,
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ import androidx.room.PrimaryKey
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an
|
* Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an
|
||||||
* DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen
|
* DocumentationEntity, damit eine Dokumentation beliebig viele Unterschriften unterschiedlicher
|
||||||
* (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter
|
* Wundversorger besitzen kann (1:n, MEDIA-004). [fileName] verweist auf eine PNG-Datei unter
|
||||||
* context.filesDir/signatures/ – kein BLOB in SQLite.
|
* context.filesDir/signatures/<documentationId>/ – kein BLOB in SQLite.
|
||||||
|
*
|
||||||
|
* Schema-Historie: In Version 2 war [signerName] noch nullable und es gab kein [orderIndex] -
|
||||||
|
* Migration 2->3 ([de.medapp.wunddoku.data.database.MIGRATION_2_3]) baut die Tabelle deshalb neu
|
||||||
|
* auf (SQLite kann eine Spalte nicht per ALTER TABLE von NULL auf NOT NULL umstellen) und füllt
|
||||||
|
* dabei sowohl [orderIndex] als auch fehlende [signerName]-Werte auf.
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "signatures",
|
tableName = "signatures",
|
||||||
@@ -28,7 +33,10 @@ import androidx.room.PrimaryKey
|
|||||||
data class SignatureEntity(
|
data class SignatureEntity(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
val documentationId: String,
|
val documentationId: String,
|
||||||
val signerName: String?,
|
/** Fachliches Pflichtfeld seit Schema 3 (non-null, getrimmt, nicht blank) - s. Repository. */
|
||||||
|
val signerName: String,
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
|
/** Stabile Reihenfolge mehrerer Signaturen einer Dokumentation, analog [de.medapp.wunddoku.data.entity.PhotoEntity.orderIndex]. */
|
||||||
|
val orderIndex: Int,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.medapp.wunddoku.data.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger-Namen samt ihrer jeweils
|
||||||
|
* AKTUELLSTEN Signatur - AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: es gibt keine
|
||||||
|
* Anmeldung, keine Rechte, keine Verknüpfung zu historischen Dokumentationen, und (vorerst) keine
|
||||||
|
* eigene Verwaltungsansicht zum Bearbeiten/Löschen einzelner Einträge - ausschließlich internes
|
||||||
|
* Cache für zwei Komfortfunktionen im Signatur-Dialog:
|
||||||
|
*
|
||||||
|
* 1. Namensvorschläge nach den ersten eingegebenen Buchstaben.
|
||||||
|
* 2. Vorbelegung von Name UND Signatur mit dem zuletzt verwendeten Wundversorger beim Öffnen des
|
||||||
|
* Dialogs (weiterhin änderbar).
|
||||||
|
*
|
||||||
|
* WICHTIG: Diese Tabelle hat KEINEN Einfluss auf bereits gespeicherte [SignatureEntity]-Zeilen -
|
||||||
|
* jede historische Signatur bleibt ein unveränderlicher Snapshot (Name + Bild) zum Zeitpunkt
|
||||||
|
* ihrer Erstellung. [signatureFileName] hier wird bei jeder neu gezeichneten Signatur unter
|
||||||
|
* demselben Namen überschrieben ("die letzte geänderte Version wird gespeichert") - komplett
|
||||||
|
* unabhängig vom Speicherort/Lebenszyklus der pro Dokumentation gespeicherten Signaturen.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "wound_carers")
|
||||||
|
data class WoundCarerEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
/** Trimmter Anzeigename, wie zuletzt eingegeben. Abgleich beim Wiedererkennen erfolgt
|
||||||
|
* case-insensitive (s. WoundCarerDao), gespeichert wird die zuletzt getippte Schreibweise. */
|
||||||
|
val name: String,
|
||||||
|
/** Relative Referenz auf die aktuellste PNG-Signatur dieses Wundversorgers, verwaltet über
|
||||||
|
* eine eigene, von [de.medapp.wunddoku.signature.SignatureStorage]-Instanz mit eigenem
|
||||||
|
* Wurzelverzeichnis (unabhängig von jeder documentationId). */
|
||||||
|
val signatureFileName: String,
|
||||||
|
/** Zeitpunkt der letzten Verwendung/Aktualisierung - bestimmt sowohl die Sortierung der
|
||||||
|
* Namensvorschläge als auch, wer als Standard beim Öffnen des Dialogs vorbelegt wird
|
||||||
|
* (jeweils der zuletzt aktualisierte Eintrag). */
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
@@ -5,7 +5,6 @@ import androidx.room.Relation
|
|||||||
import de.medapp.wunddoku.data.entity.BlockStateEntity
|
import de.medapp.wunddoku.data.entity.BlockStateEntity
|
||||||
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
||||||
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
||||||
import de.medapp.wunddoku.data.entity.PatientEntity
|
|
||||||
import de.medapp.wunddoku.data.entity.PhotoEntity
|
import de.medapp.wunddoku.data.entity.PhotoEntity
|
||||||
import de.medapp.wunddoku.data.entity.SignatureEntity
|
import de.medapp.wunddoku.data.entity.SignatureEntity
|
||||||
|
|
||||||
@@ -56,16 +55,3 @@ data class DocumentationWithDetails(
|
|||||||
)
|
)
|
||||||
val signatures: List<SignatureEntity>,
|
val signatures: List<SignatureEntity>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Schlanke Relation für Listenansichten: Dokumentation + zugehöriger Patient, ohne Blöcke/
|
|
||||||
* Fotos/Unterschriften zu laden. Wird für die Wunddokumentationsliste (Startscreen) verwendet.
|
|
||||||
*/
|
|
||||||
data class DocumentationWithPatient(
|
|
||||||
@Embedded val documentation: DocumentationEntity,
|
|
||||||
@Relation(
|
|
||||||
parentColumn = "patientId",
|
|
||||||
entityColumn = "id",
|
|
||||||
)
|
|
||||||
val patient: PatientEntity,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
package de.medapp.wunddoku.data.mapper
|
package de.medapp.wunddoku.data.mapper
|
||||||
|
|
||||||
import de.medapp.wunddoku.data.entity.BlockStateEntity
|
|
||||||
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
||||||
|
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
||||||
import de.medapp.wunddoku.data.entity.relations.BlockStateWithItems
|
import de.medapp.wunddoku.data.entity.relations.BlockStateWithItems
|
||||||
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
|
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
|
||||||
import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient
|
|
||||||
import de.medapp.wunddoku.domain.model.CheckItem
|
import de.medapp.wunddoku.domain.model.CheckItem
|
||||||
import de.medapp.wunddoku.domain.model.Documentation
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
||||||
|
import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wandelt zwischen den Room-Relations und den Domain-Modellen [Documentation]/
|
* Wandelt zwischen den Room-Relations/Entities und den Domain-Modellen [Documentation]/
|
||||||
* [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern
|
* [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern - hier
|
||||||
* ([BlockStateEntity.titleSnapshot], [CheckItemStateEntity.labelSnapshot]) – hier findet KEINE
|
* findet KEINE Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt.
|
||||||
* Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt (Architektur-
|
|
||||||
* Dokument Abschnitt 2.4).
|
|
||||||
*/
|
*/
|
||||||
fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
|
fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
|
||||||
id = documentation.id,
|
id = documentation.id,
|
||||||
patientId = documentation.patientId,
|
patientId = documentation.patientId,
|
||||||
|
patientSnapshot = documentation.toPatientSnapshot(),
|
||||||
status = documentation.status,
|
status = documentation.status,
|
||||||
createdAt = Instant.ofEpochMilli(documentation.createdAt),
|
createdAt = Instant.ofEpochMilli(documentation.createdAt),
|
||||||
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
|
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
|
||||||
@@ -40,15 +40,18 @@ fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
|
|||||||
createdAt = Instant.ofEpochMilli(it.createdAt),
|
createdAt = Instant.ofEpochMilli(it.createdAt),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
signatures = signatures.map {
|
signatures = signatures
|
||||||
de.medapp.wunddoku.domain.model.Signature(
|
.sortedBy { it.orderIndex }
|
||||||
id = it.id,
|
.map {
|
||||||
documentationId = it.documentationId,
|
de.medapp.wunddoku.domain.model.Signature(
|
||||||
signerName = it.signerName,
|
id = it.id,
|
||||||
fileName = it.fileName,
|
documentationId = it.documentationId,
|
||||||
createdAt = Instant.ofEpochMilli(it.createdAt),
|
signerName = it.signerName,
|
||||||
)
|
fileName = it.fileName,
|
||||||
},
|
orderIndex = it.orderIndex,
|
||||||
|
createdAt = Instant.ofEpochMilli(it.createdAt),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
|
fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
|
||||||
@@ -56,7 +59,6 @@ fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
|
|||||||
definitionId = blockState.blockDefinitionId,
|
definitionId = blockState.blockDefinitionId,
|
||||||
title = blockState.titleSnapshot,
|
title = blockState.titleSnapshot,
|
||||||
order = blockState.blockOrder,
|
order = blockState.blockOrder,
|
||||||
freeText = blockState.freeText,
|
|
||||||
items = checkItems
|
items = checkItems
|
||||||
.sortedBy { it.itemOrder }
|
.sortedBy { it.itemOrder }
|
||||||
.map { it.toDomain() },
|
.map { it.toDomain() },
|
||||||
@@ -68,13 +70,31 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
|
|||||||
label = labelSnapshot,
|
label = labelSnapshot,
|
||||||
order = itemOrder,
|
order = itemOrder,
|
||||||
checked = checked,
|
checked = checked,
|
||||||
|
isFreeText = isFreeText,
|
||||||
|
isStaticHint = isStaticHint,
|
||||||
|
inputSpec = inputSpec,
|
||||||
|
freeTextValue = freeTextValue,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun DocumentationWithPatient.toSummary(): DocumentationSummary = DocumentationSummary(
|
/**
|
||||||
id = documentation.id,
|
* Liefert direkt aus [DocumentationEntity] eine [DocumentationSummary] für Listenansichten -
|
||||||
patientId = documentation.patientId,
|
* ohne Join/Relation mit der Patient-Tabelle, da Name für die Anzeige bereits als Snapshot auf
|
||||||
patientFullName = "${patient.firstName} ${patient.lastName}",
|
* der Dokumentation selbst liegt (siehe PatientStammdatenSnapshot-Dokumentation).
|
||||||
status = documentation.status,
|
*/
|
||||||
createdAt = Instant.ofEpochMilli(documentation.createdAt),
|
fun DocumentationEntity.toSummary(): DocumentationSummary = DocumentationSummary(
|
||||||
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
|
id = id,
|
||||||
|
patientId = patientId,
|
||||||
|
patientFullName = "$patientFirstNameSnapshot $patientLastNameSnapshot",
|
||||||
|
status = status,
|
||||||
|
createdAt = Instant.ofEpochMilli(createdAt),
|
||||||
|
updatedAt = Instant.ofEpochMilli(updatedAt),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun DocumentationEntity.toPatientSnapshot(): PatientStammdatenSnapshot = PatientStammdatenSnapshot(
|
||||||
|
firstName = patientFirstNameSnapshot,
|
||||||
|
lastName = patientLastNameSnapshot,
|
||||||
|
birthDate = LocalDate.parse(patientBirthDateSnapshot),
|
||||||
|
street = patientStreetSnapshot,
|
||||||
|
postalCode = patientPostalCodeSnapshot,
|
||||||
|
city = patientCitySnapshot,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ import java.time.LocalDate
|
|||||||
*
|
*
|
||||||
* Kotlin-Hinweis: Dies sind sogenannte Extension Functions (`fun PatientEntity.toDomain()`)
|
* Kotlin-Hinweis: Dies sind sogenannte Extension Functions (`fun PatientEntity.toDomain()`)
|
||||||
* – sie erweitern eine bestehende Klasse um eine Methode, ohne die Klasse selbst zu verändern
|
* – sie erweitern eine bestehende Klasse um eine Methode, ohne die Klasse selbst zu verändern
|
||||||
* oder von ihr zu erben. Der Aufruf sieht dadurch wie eine normale Methode aus:
|
* oder von ihr zu erben.
|
||||||
* `patientEntity.toDomain()`. Praktisch, um Mapping-Logik nahe an den beteiligten Typen zu
|
|
||||||
* halten, ohne eine eigene Mapper-Klasse mit Konstruktor-Injection zu benötigen.
|
|
||||||
*/
|
*/
|
||||||
fun PatientEntity.toDomain(): Patient = Patient(
|
fun PatientEntity.toDomain(): Patient = Patient(
|
||||||
id = id,
|
id = id,
|
||||||
firstName = firstName,
|
firstName = firstName,
|
||||||
lastName = lastName,
|
lastName = lastName,
|
||||||
birthDate = LocalDate.parse(birthDate),
|
birthDate = LocalDate.parse(birthDate),
|
||||||
address = address,
|
street = street,
|
||||||
|
postalCode = postalCode,
|
||||||
|
city = city,
|
||||||
phone1 = phone1,
|
phone1 = phone1,
|
||||||
phone2 = phone2,
|
phone2 = phone2,
|
||||||
phone3 = phone3,
|
phone3 = phone3,
|
||||||
@@ -32,7 +32,9 @@ fun Patient.toEntity(): PatientEntity = PatientEntity(
|
|||||||
firstName = firstName,
|
firstName = firstName,
|
||||||
lastName = lastName,
|
lastName = lastName,
|
||||||
birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd)
|
birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd)
|
||||||
address = address,
|
street = street,
|
||||||
|
postalCode = postalCode,
|
||||||
|
city = city,
|
||||||
phone1 = phone1,
|
phone1 = phone1,
|
||||||
phone2 = phone2,
|
phone2 = phone2,
|
||||||
phone3 = phone3,
|
phone3 = phone3,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.room.withTransaction
|
|||||||
import de.medapp.wunddoku.data.dao.BlockStateDao
|
import de.medapp.wunddoku.data.dao.BlockStateDao
|
||||||
import de.medapp.wunddoku.data.dao.CheckItemStateDao
|
import de.medapp.wunddoku.data.dao.CheckItemStateDao
|
||||||
import de.medapp.wunddoku.data.dao.DocumentationDao
|
import de.medapp.wunddoku.data.dao.DocumentationDao
|
||||||
|
import de.medapp.wunddoku.data.dao.PatientDao
|
||||||
import de.medapp.wunddoku.data.dao.PhotoDao
|
import de.medapp.wunddoku.data.dao.PhotoDao
|
||||||
import de.medapp.wunddoku.data.dao.SignatureDao
|
import de.medapp.wunddoku.data.dao.SignatureDao
|
||||||
import de.medapp.wunddoku.data.database.AppDatabase
|
import de.medapp.wunddoku.data.database.AppDatabase
|
||||||
@@ -20,12 +21,22 @@ import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
|
|||||||
import de.medapp.wunddoku.domain.model.Documentation
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
||||||
|
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||||
import de.medapp.wunddoku.domain.model.Photo
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
import de.medapp.wunddoku.domain.model.Signature
|
import de.medapp.wunddoku.domain.model.Signature
|
||||||
|
import de.medapp.wunddoku.media.ImageDownsampler
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureFileStorage
|
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||||
|
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
||||||
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
|
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||||
|
import de.medapp.wunddoku.signature.SignatureBitmapValidation
|
||||||
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -33,7 +44,14 @@ import java.util.UUID
|
|||||||
* Koordiniert alle Lese-/Schreiboperationen rund um eine Wunddokumentation über mehrere DAOs
|
* Koordiniert alle Lese-/Schreiboperationen rund um eine Wunddokumentation über mehrere DAOs
|
||||||
* hinweg. ViewModels sehen niemals mehr als dieses eine Repository für Dokumentations-bezogene
|
* hinweg. ViewModels sehen niemals mehr als dieses eine Repository für Dokumentations-bezogene
|
||||||
* Daten – Mehrfach-DAO-Koordination und Room-Transaktionen (`db.withTransaction`) sind
|
* Daten – Mehrfach-DAO-Koordination und Room-Transaktionen (`db.withTransaction`) sind
|
||||||
* ausschließlich hier gebündelt (Architektur-Dokument Abschnitt 5).
|
* ausschließlich hier gebündelt.
|
||||||
|
*
|
||||||
|
* [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein
|
||||||
|
* frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation).
|
||||||
|
* [pdfExporter] wird für [exportPdf] benötigt - der Exporter selbst kennt weder Room noch die
|
||||||
|
* documentationId, nur das fertig gemappte [de.medapp.wunddoku.pdf.PdfExportModel]; das Laden
|
||||||
|
* der Dokumentation und die Fehlerübersetzung ("Dokumentation existiert nicht") übernimmt
|
||||||
|
* bewusst das Repository, nicht der Exporter.
|
||||||
*/
|
*/
|
||||||
class DocumentationRepository(
|
class DocumentationRepository(
|
||||||
private val documentationDao: DocumentationDao,
|
private val documentationDao: DocumentationDao,
|
||||||
@@ -41,13 +59,17 @@ class DocumentationRepository(
|
|||||||
private val checkItemStateDao: CheckItemStateDao,
|
private val checkItemStateDao: CheckItemStateDao,
|
||||||
private val photoDao: PhotoDao,
|
private val photoDao: PhotoDao,
|
||||||
private val signatureDao: SignatureDao,
|
private val signatureDao: SignatureDao,
|
||||||
|
private val patientDao: PatientDao,
|
||||||
private val photoStorage: PhotoStorage,
|
private val photoStorage: PhotoStorage,
|
||||||
private val signatureFileStorage: SignatureFileStorage,
|
private val signatureStorage: SignatureStorage,
|
||||||
|
private val woundCarerRepository: WoundCarerRepository,
|
||||||
|
private val pdfExporter: DocumentationPdfExporter,
|
||||||
|
private val pdfToImagesConverter: PdfToImagesConverter,
|
||||||
private val db: AppDatabase,
|
private val db: AppDatabase,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun observeAll(): Flow<List<DocumentationSummary>> =
|
fun observeAll(): Flow<List<DocumentationSummary>> =
|
||||||
documentationDao.observeAllWithPatient().map { list -> list.map { it.toSummary() } }
|
documentationDao.observeAll().map { list -> list.map { it.toSummary() } }
|
||||||
|
|
||||||
fun search(query: String): Flow<List<DocumentationSummary>> =
|
fun search(query: String): Flow<List<DocumentationSummary>> =
|
||||||
documentationDao.search(query).map { list -> list.map { it.toSummary() } }
|
documentationDao.search(query).map { list -> list.map { it.toSummary() } }
|
||||||
@@ -57,13 +79,15 @@ class DocumentationRepository(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt eine neue, leere Wunddokumentation für [patientId] an: eine [DocumentationEntity]
|
* Legt eine neue, leere Wunddokumentation für [patientId] an: eine [DocumentationEntity]
|
||||||
* plus je einen [BlockStateEntity] pro Katalog-Block (mit Label-/Titel-Snapshot aus dem
|
* (inkl. frischem Patienten-Stammdaten-Snapshot aus der aktuellen Patient-Tabelle) plus je
|
||||||
* *aktuellen* Katalogstand, Architektur-Dokument Abschnitt 2.4) und je einen
|
* einen [BlockStateEntity] pro Katalog-Block (mit Titel-/Reihenfolge-Snapshot aus dem
|
||||||
* [CheckItemStateEntity] pro Katalog-Checkbox. Läuft in einer einzigen Room-Transaktion,
|
* *aktuellen* Katalogstand) und je einen [CheckItemStateEntity] pro Katalog-Checkbox, PLUS
|
||||||
* damit niemals eine halb angelegte Dokumentation (z. B. Documentation-Zeile ohne
|
* eine zusätzliche Freitext-Option pro Block (isFreeText = true). Läuft in einer einzigen
|
||||||
* zugehörige Blöcke) entstehen kann.
|
* Room-Transaktion, damit niemals eine halb angelegte Dokumentation entstehen kann.
|
||||||
*/
|
*/
|
||||||
suspend fun createEmpty(patientId: String): Documentation = db.withTransaction {
|
suspend fun createEmpty(patientId: String): Documentation = db.withTransaction {
|
||||||
|
val patient = patientDao.getById(patientId)
|
||||||
|
?: error("Patient $patientId nicht gefunden")
|
||||||
val now = Instant.now().toEpochMilli()
|
val now = Instant.now().toEpochMilli()
|
||||||
val documentationId = UUID.randomUUID().toString()
|
val documentationId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
@@ -77,6 +101,12 @@ class DocumentationRepository(
|
|||||||
continuedFromDocumentationId = null,
|
continuedFromDocumentationId = null,
|
||||||
pdfLastGeneratedAt = null,
|
pdfLastGeneratedAt = null,
|
||||||
catalogVersion = DocumentationCatalog.CATALOG_VERSION,
|
catalogVersion = DocumentationCatalog.CATALOG_VERSION,
|
||||||
|
patientFirstNameSnapshot = patient.firstName,
|
||||||
|
patientLastNameSnapshot = patient.lastName,
|
||||||
|
patientBirthDateSnapshot = patient.birthDate,
|
||||||
|
patientStreetSnapshot = patient.street,
|
||||||
|
patientPostalCodeSnapshot = patient.postalCode,
|
||||||
|
patientCitySnapshot = patient.city,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,26 +116,32 @@ class DocumentationRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9, präzisiert in Architektur-
|
* "Dokumentation fortsetzen":
|
||||||
* Dokument Abschnitt 6):
|
|
||||||
* - Patient: gleiche patientId, kein neuer Patientendatensatz
|
* - Patient: gleiche patientId, kein neuer Patientendatensatz
|
||||||
* - BlockState/CheckItemState: vollständig von der Quelle kopiert, inkl. Snapshots und
|
* - Patienten-Stammdaten-Snapshot: wird FRISCH aus dem *aktuellen* Patientendatensatz
|
||||||
* Reihenfolge der QUELLE (nicht neu aus dem aktuellen Katalog gezogen) – die fortgesetzte
|
* gebildet (nicht von der Quelldokumentation kopiert) - die neue, fortgesetzte
|
||||||
* Dokumentation beginnt exakt auf dem inhaltlichen Stand des Vorgängers.
|
* Dokumentation soll den zum jetzigen Zeitpunkt aktuellen Stand des Patienten abbilden
|
||||||
|
* (z. B. nach einem Umzug), während die Quelldokumentation ihren eigenen, damaligen
|
||||||
|
* Snapshot unverändert behält.
|
||||||
|
* - BlockState/CheckItemState (Checkboxzustände, Freitexte, Snapshots, Reihenfolge):
|
||||||
|
* vollständig von der Quelle kopiert, inkl. Freitext-Option
|
||||||
* - createdAt/updatedAt: neu
|
* - createdAt/updatedAt: neu
|
||||||
* - status: IN_PROGRESS
|
* - status: IN_PROGRESS
|
||||||
* - continuedFromDocumentationId: ID der Quelle
|
* - continuedFromDocumentationId: ID der Quelle
|
||||||
* - Unterschriften: werden NICHT kopiert (eine Unterschrift bestätigt nur die konkrete
|
* - Unterschriften: werden NICHT kopiert
|
||||||
* Dokumentation, unter der sie geleistet wurde)
|
* - Fotos: werden standardmäßig mit übernommen (physisch eigenständige Kopien pro
|
||||||
* - Fotos: über [copyPhotos] steuerbar, Standard `false` – fachlich noch nicht final
|
* Dokumentation, s. [PhotoStorage.duplicate]) - fachliche Entscheidung, ursprünglich als
|
||||||
* entschieden (siehe Architektur-Dokument Abschnitt 6)
|
* offen markiert, inzwischen bewusst so festgelegt. Über [copyPhotos] weiterhin
|
||||||
|
* abschaltbar, falls dafür später doch ein Anwendungsfall entsteht.
|
||||||
*/
|
*/
|
||||||
suspend fun continueDocumentation(
|
suspend fun continueDocumentation(
|
||||||
sourceDocumentationId: String,
|
sourceDocumentationId: String,
|
||||||
copyPhotos: Boolean = false,
|
copyPhotos: Boolean = true,
|
||||||
): Documentation = db.withTransaction {
|
): Documentation = db.withTransaction {
|
||||||
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
|
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
|
||||||
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
|
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
|
||||||
|
val patient = patientDao.getById(source.documentation.patientId)
|
||||||
|
?: error("Patient ${source.documentation.patientId} nicht gefunden")
|
||||||
|
|
||||||
val now = Instant.now().toEpochMilli()
|
val now = Instant.now().toEpochMilli()
|
||||||
val newDocumentationId = UUID.randomUUID().toString()
|
val newDocumentationId = UUID.randomUUID().toString()
|
||||||
@@ -120,10 +156,16 @@ class DocumentationRepository(
|
|||||||
continuedFromDocumentationId = sourceDocumentationId,
|
continuedFromDocumentationId = sourceDocumentationId,
|
||||||
pdfLastGeneratedAt = null,
|
pdfLastGeneratedAt = null,
|
||||||
catalogVersion = source.documentation.catalogVersion,
|
catalogVersion = source.documentation.catalogVersion,
|
||||||
|
patientFirstNameSnapshot = patient.firstName,
|
||||||
|
patientLastNameSnapshot = patient.lastName,
|
||||||
|
patientBirthDateSnapshot = patient.birthDate,
|
||||||
|
patientStreetSnapshot = patient.street,
|
||||||
|
patientPostalCodeSnapshot = patient.postalCode,
|
||||||
|
patientCitySnapshot = patient.city,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Blöcke + Checkboxen 1:1 von der Quelle übernehmen (inkl. Snapshots/Reihenfolge)
|
// Blöcke + Checkboxen (inkl. Freitext-Option) 1:1 von der Quelle übernehmen
|
||||||
for (sourceBlock in source.blocks) {
|
for (sourceBlock in source.blocks) {
|
||||||
val newBlockStateId = UUID.randomUUID().toString()
|
val newBlockStateId = UUID.randomUUID().toString()
|
||||||
blockStateDao.insertAll(
|
blockStateDao.insertAll(
|
||||||
@@ -134,7 +176,6 @@ class DocumentationRepository(
|
|||||||
blockDefinitionId = sourceBlock.blockState.blockDefinitionId,
|
blockDefinitionId = sourceBlock.blockState.blockDefinitionId,
|
||||||
titleSnapshot = sourceBlock.blockState.titleSnapshot,
|
titleSnapshot = sourceBlock.blockState.titleSnapshot,
|
||||||
blockOrder = sourceBlock.blockState.blockOrder,
|
blockOrder = sourceBlock.blockState.blockOrder,
|
||||||
freeText = sourceBlock.blockState.freeText,
|
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -148,6 +189,10 @@ class DocumentationRepository(
|
|||||||
labelSnapshot = item.labelSnapshot,
|
labelSnapshot = item.labelSnapshot,
|
||||||
itemOrder = item.itemOrder,
|
itemOrder = item.itemOrder,
|
||||||
checked = item.checked,
|
checked = item.checked,
|
||||||
|
isFreeText = item.isFreeText,
|
||||||
|
isStaticHint = item.isStaticHint,
|
||||||
|
freeTextValue = item.freeTextValue,
|
||||||
|
inputSpec = item.inputSpec,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -157,7 +202,11 @@ class DocumentationRepository(
|
|||||||
|
|
||||||
if (copyPhotos) {
|
if (copyPhotos) {
|
||||||
for (sourcePhoto in source.photos) {
|
for (sourcePhoto in source.photos) {
|
||||||
val copiedFileName = photoStorage.duplicate(sourcePhoto.fileName)
|
val copiedFileName = photoStorage.duplicate(
|
||||||
|
sourceDocumentationId = source.documentation.id,
|
||||||
|
sourceFileName = sourcePhoto.fileName,
|
||||||
|
targetDocumentationId = newDocumentationId,
|
||||||
|
)
|
||||||
photoDao.insert(
|
photoDao.insert(
|
||||||
PhotoEntity(
|
PhotoEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
@@ -174,27 +223,33 @@ class DocumentationRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Komfort-Methode für die Patientenliste (Aufgabenstellung Abschnitt 6, Aktion
|
* Komfort-Methode für die Patientenliste ("Behandlung/Dokumentation fortsetzen"): ermittelt
|
||||||
* "Behandlung/Dokumentation fortsetzen"): ermittelt die zuletzt erstellte Dokumentation
|
* die zuletzt erstellte Dokumentation eines Patienten und ruft darauf
|
||||||
* eines Patienten und ruft darauf [continueDocumentation] auf. Liefert `null`, wenn der
|
* [continueDocumentation] auf. Liefert `null`, wenn der Patient noch keine Dokumentation
|
||||||
* Patient noch keine Dokumentation besitzt (UI muss diesen Fall abfangen, s. Aufgabenstellung
|
* besitzt.
|
||||||
* Abschnitt 20).
|
|
||||||
*/
|
*/
|
||||||
suspend fun continueLatestForPatient(patientId: String): Documentation? {
|
suspend fun continueLatestForPatient(patientId: String): Documentation? {
|
||||||
val latestId = documentationDao.getLatestForPatient(patientId)?.documentation?.id ?: return null
|
val latestId = documentationDao.getLatestForPatient(patientId)?.documentation?.id ?: return null
|
||||||
return continueDocumentation(latestId)
|
return continueDocumentation(latestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateFreeText(blockStateId: String, text: String) {
|
/** Speichert den Zustand einer normalen Checkbox ODER der Freitext-Checkbox eines Blocks. */
|
||||||
blockStateDao.updateFreeText(blockStateId, text, Instant.now().toEpochMilli())
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setChecked(checkItemStateId: String, checked: Boolean) {
|
suspend fun setChecked(checkItemStateId: String, checked: Boolean) {
|
||||||
checkItemStateDao.setChecked(checkItemStateId, checked)
|
checkItemStateDao.setChecked(checkItemStateId, checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert den Freitext der Freitext-Option eines Blocks. Der Freitext ist seit der
|
||||||
|
* Umstellung auf eine Checkbox-Option kein Feld am BlockState mehr, sondern liegt am
|
||||||
|
* jeweiligen CheckItemState (isFreeText = true) - [checkItemStateId] referenziert deshalb
|
||||||
|
* dieses CheckItem, nicht den Block.
|
||||||
|
*/
|
||||||
|
suspend fun updateFreeText(checkItemStateId: String, text: String) {
|
||||||
|
checkItemStateDao.updateFreeTextValue(checkItemStateId, text)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
|
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
|
||||||
val fileName = photoStorage.copyFrom(sourceUri)
|
val fileName = photoStorage.importFrom(documentationId, sourceUri)
|
||||||
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
val entity = PhotoEntity(
|
val entity = PhotoEntity(
|
||||||
@@ -209,55 +264,164 @@ class DocumentationRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Eine Room-Transaktion
|
* Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Reihenfolge DB-Zeile
|
||||||
* kann keine Dateisystemoperation einschließen. Reihenfolge: DB-Zeile zuerst, dann Datei –
|
* zuerst, dann Datei - schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei
|
||||||
* schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei zurück (unkritisch),
|
* zurück (unkritisch), statt eines DB-Eintrags, der auf eine nicht mehr existente Datei
|
||||||
* statt eines DB-Eintrags, der auf eine nicht mehr existente Datei verweist (würde beim
|
* verweist.
|
||||||
* Anzeigen/PDF-Export zu einem sichtbaren Fehler führen). Siehe Architektur-Dokument
|
|
||||||
* Abschnitt 5.1.
|
|
||||||
*/
|
*/
|
||||||
suspend fun removePhoto(photoId: String) {
|
suspend fun removePhoto(photoId: String) {
|
||||||
val photo = photoDao.getById(photoId) ?: return
|
val photo = photoDao.getById(photoId) ?: return
|
||||||
photoDao.delete(photo)
|
photoDao.delete(photo)
|
||||||
runCatching { photoStorage.delete(photo.fileName) }
|
runCatching { photoStorage.delete(photo.documentationId, photo.fileName) }
|
||||||
// Fehler beim Löschen der physischen Datei wird bewusst verschluckt: Die DB ist bereits
|
|
||||||
// konsistent, eine verwaiste Datei ist der unkritischere Fehlerfall. Kein Logging von
|
|
||||||
// Dateinamen mit Patientenbezug, s. Datenschutz-Abschnitt der Architektur.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature {
|
/**
|
||||||
val fileName = signatureFileStorage.save(bitmap)
|
* Erzeugt aus einer Wunddokumentation ein PDF im internen App-Speicher (PDF-001/002). Lädt
|
||||||
|
* die vollständige Dokumentation, übersetzt "nicht gefunden" in einen fachlichen Fehler
|
||||||
|
* statt einer NullPointerException, mappt anschließend auf das schlanke [de.medapp.wunddoku.pdf.PdfExportModel]
|
||||||
|
* (dabei werden über [DocumentationToPdfExportModelMapper] ausschließlich bearbeitete
|
||||||
|
* Blöcke gemäß der bestehenden BlockEditedPolicy sowie die historischen Snapshot-Werte
|
||||||
|
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
|
||||||
|
*/
|
||||||
|
suspend fun exportPdf(documentationId: String): PdfExportResult {
|
||||||
|
val documentation = getFullDocumentation(documentationId)
|
||||||
|
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
|
||||||
|
val model = DocumentationToPdfExportModelMapper.map(documentation)
|
||||||
|
return pdfExporter.export(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
|
||||||
|
* Compose ausschließlich über das ViewModel aufgerufen, nie direkt - siehe Architektur:
|
||||||
|
* Compose führt keine Dateizugriffe selbst durch.
|
||||||
|
*/
|
||||||
|
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
|
||||||
|
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt ein Foto in Originalgröße für die Vollbildansicht beim Antippen einer Foto-Kachel
|
||||||
|
* (Änderungswunsch). Verwendet bewusst [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]
|
||||||
|
* statt eines unskalierten Rohzugriffs: Fotos werden bereits beim Import auf eine sinnvolle
|
||||||
|
* Maximalauflösung begrenzt (s. PhotoStorage-Importlogik), ein zusätzliches Herunterrechnen
|
||||||
|
* auf höchstens die tatsächliche Displaygröße ([maxWidthPx]/[maxHeightPx], vom Aufrufer aus
|
||||||
|
* der Bildschirmauflösung ermittelt) vermeidet unnötig hohen Speicherbedarf, ohne das Bild
|
||||||
|
* sichtbar zu verkleinern - der Nutzer sieht es exakt so groß wie der Bildschirm es ohnehin
|
||||||
|
* darstellen könnte.
|
||||||
|
*/
|
||||||
|
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? =
|
||||||
|
photoStorage.decodeFitted(documentationId, fileName, maxWidthPx, maxHeightPx)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
|
||||||
|
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
|
||||||
|
* genug für die direkte Anzeige. Liefert `null` bei fehlender/beschädigter Datei (kein
|
||||||
|
* Absturz, konsistent zu [loadPhotoThumbnail]).
|
||||||
|
*/
|
||||||
|
suspend fun loadSignatureBitmap(documentationId: String, fileName: String): Bitmap? =
|
||||||
|
signatureStorage.decode(documentationId, fileName)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert eine neue Signatur (MEDIA-003/004). [signerName] ist fachliches Pflichtfeld:
|
||||||
|
* wird getrimmt, ein danach leerer String wird abgelehnt (Aufgabenstellung Abschnitt 8) -
|
||||||
|
* ebenso eine leere Zeichnung ([SignatureBitmapValidation.isBlank], Abschnitt 12: zweite,
|
||||||
|
* von der UI unabhängige Absicherungsebene). [orderIndex] wird analog [addPhoto] fortlaufend
|
||||||
|
* je Dokumentation vergeben, damit mehrere Signaturen eine stabile Reihenfolge haben
|
||||||
|
* (Anzeige UND spätere PDF-Reihenfolge, PDF-003). Die Namensvalidierung erfolgt VOR dem
|
||||||
|
* Schreiben der PNG-Datei, damit bei einem ungültigen Namen keine verwaiste Datei entsteht.
|
||||||
|
*
|
||||||
|
* Aktualisiert nach erfolgreichem Speichern zusätzlich das Wundversorger-Register
|
||||||
|
* ([WoundCarerRepository.recordSignatureUsage]) mit derselben Signatur - unabhängig davon,
|
||||||
|
* ob [bitmap] frisch gezeichnet oder aus einem Namensvorschlag im Dialog übernommen wurde.
|
||||||
|
* Das hat KEINEN Einfluss auf den hier gerade erzeugten, unveränderlichen [Signature]-
|
||||||
|
* Snapshot dieser Dokumentation.
|
||||||
|
*/
|
||||||
|
suspend fun saveSignature(documentationId: String, signerName: String, bitmap: Bitmap): Signature {
|
||||||
|
val trimmedName = signerName.trim()
|
||||||
|
require(trimmedName.isNotEmpty()) { "Der Name des Wundversorgers darf nicht leer sein." }
|
||||||
|
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
|
||||||
|
|
||||||
|
val fileName = signatureStorage.save(documentationId, bitmap)
|
||||||
|
val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
val entity = SignatureEntity(
|
val entity = SignatureEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
documentationId = documentationId,
|
documentationId = documentationId,
|
||||||
signerName = signerName,
|
signerName = trimmedName,
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
|
orderIndex = nextOrderIndex,
|
||||||
createdAt = now.toEpochMilli(),
|
createdAt = now.toEpochMilli(),
|
||||||
)
|
)
|
||||||
signatureDao.insert(entity)
|
signatureDao.insert(entity)
|
||||||
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now)
|
woundCarerRepository.recordSignatureUsage(trimmedName, bitmap)
|
||||||
|
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */
|
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. Signaturen sind nach dem
|
||||||
|
* Speichern unveränderlich (Aufgabenstellung Abschnitt 17) - "korrigieren" bedeutet fachlich
|
||||||
|
* immer löschen + neu erfassen, nie ein nachträgliches Bearbeiten. */
|
||||||
suspend fun removeSignature(signatureId: String) {
|
suspend fun removeSignature(signatureId: String) {
|
||||||
val signature = signatureDao.getById(signatureId) ?: return
|
val signature = signatureDao.getById(signatureId) ?: return
|
||||||
signatureDao.delete(signature)
|
signatureDao.delete(signature)
|
||||||
runCatching { signatureFileStorage.delete(signature.fileName) }
|
runCatching { signatureStorage.delete(signature.documentationId, signature.fileName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun delete(documentationId: String) {
|
suspend fun delete(documentationId: String) {
|
||||||
val entity = documentationDao.getById(documentationId) ?: return
|
val entity = documentationDao.getById(documentationId) ?: return
|
||||||
// Zugehörige Fotos/Signaturen zuerst physisch löschen (best effort), dann die
|
|
||||||
// Dokumentation selbst – CASCADE übernimmt das Entfernen der DB-Zeilen für
|
|
||||||
// BlockState/CheckItemState/Photo/Signature.
|
|
||||||
val photos = photoDao.getForDocumentation(documentationId)
|
|
||||||
val signatures = signatureDao.getForDocumentation(documentationId)
|
|
||||||
documentationDao.delete(entity)
|
documentationDao.delete(entity)
|
||||||
photos.forEach { runCatching { photoStorage.delete(it.fileName) } }
|
// Je ein einziger Verzeichnis-Löschvorgang statt Einzeldateien für Fotos UND Signaturen
|
||||||
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } }
|
// - beide Storage-Implementierungen organisieren ihre Dateien pro documentationId in
|
||||||
|
// einem eigenen Unterordner (s. PhotoStorage-/SignatureStorage-Kommentar). Room-Cascade
|
||||||
|
// entfernt zwar die PhotoEntity-/SignatureEntity-Zeilen automatisch, aber keine Dateien
|
||||||
|
// aus filesDir - das übernimmt ausschließlich diese Methode. Da deleteAll() über den
|
||||||
|
// Ordnernamen (documentationId) statt über zuvor geladene DB-Zeilen arbeitet, muss hier
|
||||||
|
// - anders als vorher bei den Signaturen - auch keine Liste mehr vor dem DB-Delete
|
||||||
|
// geladen werden.
|
||||||
|
runCatching { photoStorage.deleteAll(documentationId) }
|
||||||
|
runCatching { signatureStorage.deleteAll(documentationId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
|
||||||
|
* Katalog-Eintrag PLUS eine zusätzliche, automatisch angehängte generische Freitext-Option
|
||||||
|
* (isFreeText = true, Label "Freitext") - siehe DocumentationCatalog.FREE_TEXT_LABEL und
|
||||||
|
* 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`,
|
||||||
|
* `isStaticHint` und `inputSpec` steuern direkt das Verhalten der jeweiligen Zeile (normale
|
||||||
|
* Checkbox, eigene benannte Freitext-Checkbox, nicht editierbarer Hinweistext, oder
|
||||||
|
* strukturierte Eingabefelder) - 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.
|
||||||
|
*
|
||||||
|
* Änderungswunsch: Die generische Freitext-Option erscheint jetzt als LETZTES Element im
|
||||||
|
* Block (itemOrder = höchste vorkommende Katalog-`order` + 1) - zuvor war sie das ERSTE
|
||||||
|
* Element (itemOrder = 0). Vom Katalog selbst definierte, eigens benannte Freitext-Einträge
|
||||||
|
* (isFreeText = true) behalten dagegen ihre im Katalog festgelegte Position, sofern der
|
||||||
|
* jeweilige Block das explizit so vorsieht. Betrifft ausschließlich NEU erstellte
|
||||||
|
* Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen behalten ihre
|
||||||
|
* historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s. Klassendokumentation
|
||||||
|
* zu titleSnapshot/labelSnapshot).
|
||||||
|
*/
|
||||||
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
|
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
|
||||||
for (blockDef in DocumentationCatalog.blocks) {
|
for (blockDef in DocumentationCatalog.blocks) {
|
||||||
val blockStateId = UUID.randomUUID().toString()
|
val blockStateId = UUID.randomUUID().toString()
|
||||||
@@ -269,23 +433,44 @@ class DocumentationRepository(
|
|||||||
blockDefinitionId = blockDef.id,
|
blockDefinitionId = blockDef.id,
|
||||||
titleSnapshot = blockDef.title,
|
titleSnapshot = blockDef.title,
|
||||||
blockOrder = blockDef.order,
|
blockOrder = blockDef.order,
|
||||||
freeText = "",
|
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
checkItemStateDao.insertAll(
|
|
||||||
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
|
||||||
)
|
// anderen Einträge (Checkbox, eigene Freitext-Checkbox oder strukturierte
|
||||||
}
|
// Eingabe) starten unangehakt/leer, wie bisher.
|
||||||
|
checked = itemDef.isStaticHint,
|
||||||
|
isFreeText = itemDef.isFreeText,
|
||||||
|
isStaticHint = itemDef.isStaticHint,
|
||||||
|
freeTextValue = "",
|
||||||
|
inputSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Änderungswunsch: generische Freitext-Option jetzt NACH allen Katalog-Einträgen
|
||||||
|
// dieses Blocks (s. Methodenkommentar) statt davor.
|
||||||
|
val genericFreeTextOrder = (blockDef.items.maxOfOrNull { it.order } ?: 0) + 1
|
||||||
|
val genericFreeTextItem = CheckItemStateEntity(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
blockStateId = blockStateId,
|
||||||
|
checkItemDefinitionId = blockDef.freeTextDefinitionId,
|
||||||
|
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
|
||||||
|
itemOrder = genericFreeTextOrder,
|
||||||
|
checked = false,
|
||||||
|
isFreeText = true,
|
||||||
|
isStaticHint = false,
|
||||||
|
freeTextValue = "",
|
||||||
|
inputSpec = "",
|
||||||
)
|
)
|
||||||
|
checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ class PatientRepository(
|
|||||||
id = patient.id,
|
id = patient.id,
|
||||||
fullName = "${patient.firstName} ${patient.lastName}",
|
fullName = "${patient.firstName} ${patient.lastName}",
|
||||||
birthDate = LocalDate.parse(patient.birthDate),
|
birthDate = LocalDate.parse(patient.birthDate),
|
||||||
address = patient.address,
|
street = patient.street,
|
||||||
|
postalCode = patient.postalCode,
|
||||||
|
city = patient.city,
|
||||||
lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) },
|
lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.medapp.wunddoku.data.repository
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import de.medapp.wunddoku.data.dao.WoundCarerDao
|
||||||
|
import de.medapp.wunddoku.data.entity.WoundCarerEntity
|
||||||
|
import de.medapp.wunddoku.domain.model.WoundCarer
|
||||||
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger (Namensvorschläge +
|
||||||
|
* Vorbelegung im Signatur-Dialog mit Name UND Signatur des zuletzt verwendeten Wundversorgers).
|
||||||
|
*
|
||||||
|
* AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: keine Anmeldung, keine Rechte, keine
|
||||||
|
* Verknüpfung zu [DocumentationRepository]-Signaturen, und (vorerst) keine eigene
|
||||||
|
* Verwaltungsansicht. Ändert insbesondere NIE bereits gespeicherte, historische
|
||||||
|
* [de.medapp.wunddoku.domain.model.Signature]-Snapshots einzelner Dokumentationen - diese bleiben
|
||||||
|
* unveränderlich, wie ursprünglich festgelegt. Diese Klasse verwaltet eine komplett separate
|
||||||
|
* Tabelle (`wound_carers`) und ein separates Dateiverzeichnis
|
||||||
|
* (`filesDir/wound_carer_signatures/`, s. [de.medapp.wunddoku.signature.InternalSignatureStorage]).
|
||||||
|
*/
|
||||||
|
class WoundCarerRepository(
|
||||||
|
private val woundCarerDao: WoundCarerDao,
|
||||||
|
private val woundCarerSignatureStorage: SignatureStorage,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Für die Vorbelegung des Signatur-Dialogs beim Öffnen: der zuletzt verwendete
|
||||||
|
* Wundversorger (app-weit, nicht auf eine einzelne Dokumentation beschränkt) - `null`, wenn
|
||||||
|
* noch nie eine Signatur gespeichert wurde. */
|
||||||
|
suspend fun getMostRecentlyUsed(): WoundCarer? = woundCarerDao.getMostRecentlyUsed()?.toDomain()
|
||||||
|
|
||||||
|
/** Namensvorschläge nach den ersten eingegebenen Buchstaben. Leerer/blank [prefix] liefert
|
||||||
|
* bewusst keine Vorschläge (kein "alle anzeigen" bei leerem Feld). */
|
||||||
|
suspend fun search(prefix: String, limit: Int = 5): List<WoundCarer> {
|
||||||
|
val trimmed = prefix.trim()
|
||||||
|
if (trimmed.isEmpty()) return emptyList()
|
||||||
|
return woundCarerDao.searchByNamePrefix(trimmed, limit).map { it.toDomain() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt die aktuell hinterlegte Signatur eines bekannten Wundversorgers, z. B. zur Anzeige
|
||||||
|
* einer Vorschau im Signatur-Dialog nach Auswahl eines Namensvorschlags. */
|
||||||
|
suspend fun loadSignatureBitmap(woundCarerId: String, fileName: String): Bitmap? =
|
||||||
|
woundCarerSignatureStorage.decode(woundCarerId, fileName)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird bei JEDER erfolgreich gespeicherten Signatur aufgerufen (s.
|
||||||
|
* [DocumentationRepository.saveSignature]) und aktualisiert das Register: existiert unter
|
||||||
|
* [name] (getrimmt, case-insensitiver Abgleich) bereits ein Eintrag, wird dessen Signatur
|
||||||
|
* durch [bitmap] ersetzt (alte Datei wird gelöscht) und der Zeitstempel aktualisiert -
|
||||||
|
* "die letzte geänderte Version der Unterschrift eines Wundversorgers wird gespeichert".
|
||||||
|
* Andernfalls wird ein neuer Eintrag angelegt. Verwendet dieselbe [bitmap]-Instanz, die auch
|
||||||
|
* als historischer Snapshot der jeweiligen Dokumentation gespeichert wird - es wird lediglich
|
||||||
|
* eine ZWEITE, unabhängige PNG-Kopie in einem eigenen Verzeichnis abgelegt (kein Teilen
|
||||||
|
* derselben Datei), damit ein späteres Löschen der Dokumentation dieses Register niemals
|
||||||
|
* beeinträchtigen kann.
|
||||||
|
*/
|
||||||
|
suspend fun recordSignatureUsage(name: String, bitmap: Bitmap) {
|
||||||
|
val existing = woundCarerDao.findByName(name)
|
||||||
|
val id = existing?.id ?: UUID.randomUUID().toString()
|
||||||
|
val newFileName = woundCarerSignatureStorage.save(id, bitmap)
|
||||||
|
woundCarerDao.insertOrUpdate(
|
||||||
|
WoundCarerEntity(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
signatureFileName = newFileName,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val oldFileName = existing?.signatureFileName
|
||||||
|
if (oldFileName != null && oldFileName != newFileName) {
|
||||||
|
runCatching { woundCarerSignatureStorage.delete(id, oldFileName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun WoundCarerEntity.toDomain() = WoundCarer(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
signatureFileName = signatureFileName,
|
||||||
|
updatedAt = Instant.ofEpochMilli(updatedAt),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,10 +4,15 @@ import android.content.Context
|
|||||||
import de.medapp.wunddoku.data.database.AppDatabase
|
import de.medapp.wunddoku.data.database.AppDatabase
|
||||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||||
|
import de.medapp.wunddoku.data.repository.WoundCarerRepository
|
||||||
import de.medapp.wunddoku.media.InternalPhotoStorage
|
import de.medapp.wunddoku.media.InternalPhotoStorage
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
import de.medapp.wunddoku.signature.InternalSignatureFileStorage
|
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
||||||
import de.medapp.wunddoku.signature.SignatureFileStorage
|
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
|
||||||
|
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||||
|
import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
||||||
|
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
||||||
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories,
|
* Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories,
|
||||||
@@ -26,12 +31,28 @@ class AppContainer(context: Context) {
|
|||||||
private val database: AppDatabase by lazy { AppDatabase.getInstance(context) }
|
private val database: AppDatabase by lazy { AppDatabase.getInstance(context) }
|
||||||
|
|
||||||
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
|
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
|
||||||
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) }
|
private val signatureStorage: SignatureStorage by lazy { InternalSignatureStorage(context) }
|
||||||
|
/** Eigenes Wurzelverzeichnis, komplett unabhängig von den dokumentationsbezogenen
|
||||||
|
* Signaturen - s. [InternalSignatureStorage]-Klassenkommentar. */
|
||||||
|
private val woundCarerSignatureStorage: SignatureStorage by lazy {
|
||||||
|
InternalSignatureStorage(context, rootDirName = "wound_carer_signatures")
|
||||||
|
}
|
||||||
|
private val pdfExporter: DocumentationPdfExporter by lazy {
|
||||||
|
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
|
||||||
|
}
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val woundCarerRepository: WoundCarerRepository by lazy {
|
||||||
|
WoundCarerRepository(
|
||||||
|
woundCarerDao = database.woundCarerDao(),
|
||||||
|
woundCarerSignatureStorage = woundCarerSignatureStorage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val documentationRepository: DocumentationRepository by lazy {
|
val documentationRepository: DocumentationRepository by lazy {
|
||||||
DocumentationRepository(
|
DocumentationRepository(
|
||||||
documentationDao = database.documentationDao(),
|
documentationDao = database.documentationDao(),
|
||||||
@@ -39,8 +60,12 @@ class AppContainer(context: Context) {
|
|||||||
checkItemStateDao = database.checkItemStateDao(),
|
checkItemStateDao = database.checkItemStateDao(),
|
||||||
photoDao = database.photoDao(),
|
photoDao = database.photoDao(),
|
||||||
signatureDao = database.signatureDao(),
|
signatureDao = database.signatureDao(),
|
||||||
|
patientDao = database.patientDao(),
|
||||||
photoStorage = photoStorage,
|
photoStorage = photoStorage,
|
||||||
signatureFileStorage = signatureFileStorage,
|
signatureStorage = signatureStorage,
|
||||||
|
woundCarerRepository = woundCarerRepository,
|
||||||
|
pdfExporter = pdfExporter,
|
||||||
|
pdfToImagesConverter = pdfToImagesConverter,
|
||||||
db = database,
|
db = database,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,598 @@
|
|||||||
package de.medapp.wunddoku.domain.catalog
|
package de.medapp.wunddoku.domain.catalog
|
||||||
|
|
||||||
|
import de.medapp.wunddoku.domain.model.InputFieldDef
|
||||||
|
import de.medapp.wunddoku.domain.model.InputSpec
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
|
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
|
||||||
*
|
*
|
||||||
* 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, zunächst ergänzt/verfeinert nach Abgleich
|
||||||
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser
|
* mit dem ursprünglichen Papierformular, und in dieser Fassung ein weiteres Mal überarbeitet
|
||||||
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden.
|
* nach konkreten Änderungswünschen (u. a. neue Checkbox-Listen für Wundart/Vitalwerte/
|
||||||
|
* Auswirkungen aus Fotos, strukturierte Eingabefelder statt Freitext für Maße/Uhrzeiten/Tiefen,
|
||||||
|
* ein neuer Abschnitt "Wundheilung beeinflussende Faktoren").
|
||||||
|
*
|
||||||
|
* WICHTIGE GENERELLE REGEL (Änderungswunsch): Die automatisch an jeden Block angehängte
|
||||||
|
* generische Freitext-Option (s. [FREE_TEXT_LABEL]) erscheint jetzt als LETZTES Element eines
|
||||||
|
* Blocks (vorher: als erstes) - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.insertBlocksFromCatalog].
|
||||||
|
* Vom Katalog selbst eigens benannte Freitext-Einträge (`isFreeText = true` an einem konkreten
|
||||||
|
* [CheckItemDefinition]) behalten dagegen ihre hier festgelegte Position.
|
||||||
|
*
|
||||||
|
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
|
||||||
|
* Inhalte (Wortlaut-Variante der alten Wundart-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
|
||||||
* Abschnitt 2.3b.
|
* Abschnitt 2.3b.
|
||||||
*
|
*
|
||||||
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
|
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
|
||||||
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
|
* Metadatum in Documentation.catalogVersion übernommen. Auf 4 erhöht mit diesem Umbau.
|
||||||
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
|
|
||||||
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
|
|
||||||
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots).
|
|
||||||
*/
|
*/
|
||||||
object DocumentationCatalog {
|
object DocumentationCatalog {
|
||||||
|
|
||||||
const val CATALOG_VERSION: Int = 1
|
const val CATALOG_VERSION: Int = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
|
||||||
|
* damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe 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"
|
||||||
|
|
||||||
|
// Wiederverwendete Auswahllisten für strukturierte Eingabefelder (s. InputFieldDef.Choice).
|
||||||
|
private val HOUR_OPTIONS: List<String> = (1..12).map { it.toString() }
|
||||||
|
private val WEEKDAY_COUNT_OPTIONS: List<String> = (1..7).map { it.toString() }
|
||||||
|
private val MINUTE_1_TO_15_OPTIONS: List<String> = (1..15).map { it.toString() }
|
||||||
|
private val DEPTH_HALF_CM_OPTIONS: List<String> = listOf(
|
||||||
|
"0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm",
|
||||||
|
"4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm",
|
||||||
|
)
|
||||||
|
private val DECUBITUS_GRADE_OPTIONS: List<String> = listOf("I", "II", "III", "IV")
|
||||||
|
|
||||||
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 = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
|
||||||
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
|
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "wundart_decubitus",
|
||||||
|
label = "Decubitus",
|
||||||
|
order = 3,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Decubitus Grad {0}",
|
||||||
|
fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4),
|
||||||
|
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5),
|
||||||
|
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
|
||||||
|
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum_arteriosum", label = "Ulcus Cruris ven.-art.", order = 7),
|
||||||
|
CheckItemDefinition(id = "wundart_diabetisches_fusssyndrom", label = "Diab. Fuß Syndrom", order = 8),
|
||||||
|
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung / Dehiszenz", order = 9),
|
||||||
|
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 10),
|
||||||
|
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 11),
|
||||||
|
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 12),
|
||||||
|
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 13),
|
||||||
|
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 14),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "wundart_transplantatverlust",
|
||||||
|
label = "Wunde nach Transplantatverlust",
|
||||||
|
order = 15,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Wunde nach Transplantatverlust, Spalthautdeckung ca. {0}%",
|
||||||
|
fields = listOf(InputFieldDef.Number(label = "Spalthautdeckung ca. (%)", maxLength = 3)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 16),
|
||||||
|
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 17),
|
||||||
|
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 18),
|
||||||
|
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 19),
|
||||||
|
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 20),
|
||||||
|
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 21),
|
||||||
|
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 22),
|
||||||
|
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 23),
|
||||||
|
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
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_hinweis_masse", label = "Maße", order = 1, isStaticHint = true),
|
||||||
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2),
|
CheckItemDefinition(
|
||||||
|
id = "groesse_laenge_breite_tiefe",
|
||||||
|
label = "Maße (Länge / Breite / Tiefe)",
|
||||||
|
order = 2,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Länge {0} cm, Breite {1} cm, Tiefe {2} cm",
|
||||||
|
fields = listOf(
|
||||||
|
InputFieldDef.Number(label = "Länge (cm)", maxLength = 5, allowDecimal = true),
|
||||||
|
InputFieldDef.Number(label = "Breite (cm)", maxLength = 5, allowDecimal = true),
|
||||||
|
InputFieldDef.Number(label = "Tiefe (cm)", maxLength = 5, allowDecimal = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "groesse_hinweis_areal", label = "Zu versorgendes Areal", order = 5, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6),
|
||||||
|
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 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 = listOf(
|
||||||
|
CheckItemDefinition(id = "verlauf_positiv", label = "Positiv", order = 1),
|
||||||
|
CheckItemDefinition(id = "verlauf_negativ", label = "Negativ", order = 2),
|
||||||
|
CheckItemDefinition(id = "verlauf_stagnierend", label = "Stagnierend", order = 3),
|
||||||
|
CheckItemDefinition(id = "verlauf_nicht_bekannt", label = "Nicht bekannt", order = 4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
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_wie_ausgestanzt", label = "Wie ausgestanzt", order = 1),
|
||||||
|
CheckItemDefinition(id = "wundgrund_kraterfoermig", label = "Kraterförmig", order = 2),
|
||||||
|
CheckItemDefinition(id = "wundgrund_unregelmaessig_landkartenartig", label = "Unregelmäßig (landkartenartig/amorph)", order = 3),
|
||||||
|
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 4),
|
||||||
|
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 5),
|
||||||
|
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 6),
|
||||||
|
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 7),
|
||||||
|
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 8),
|
||||||
|
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 9),
|
||||||
|
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 10),
|
||||||
|
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 11),
|
||||||
|
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 12),
|
||||||
|
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 13),
|
||||||
|
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 14),
|
||||||
|
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 15),
|
||||||
|
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 16),
|
||||||
|
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 17),
|
||||||
|
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 18),
|
||||||
|
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 19),
|
||||||
|
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 20, isFreeText = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
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_biofilm", label = "Biofilm", order = 7),
|
||||||
|
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 8),
|
||||||
|
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 9),
|
||||||
|
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 10),
|
||||||
|
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 11),
|
||||||
|
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 12),
|
||||||
|
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 13),
|
||||||
|
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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_sehr_trockene_haut", label = "Sehr trockene Haut", order = 11),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 12),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 13),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 14),
|
||||||
|
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Von {0} bis {1} Uhr",
|
||||||
|
fields = listOf(
|
||||||
|
InputFieldDef.Choice(label = "Von", options = HOUR_OPTIONS),
|
||||||
|
InputFieldDef.Choice(label = "Bis", options = HOUR_OPTIONS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "fistel_tasche_tiefe",
|
||||||
|
label = "Tiefe",
|
||||||
|
order = 5,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Tiefe: {0}",
|
||||||
|
fields = listOf(InputFieldDef.Choice(label = "Tiefe", options = DEPTH_HALF_CM_OPTIONS)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
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 = listOf(
|
||||||
|
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_links", label = "Arteria tibialis posterior links", order = 1),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "per Doppler (Taschendoppler)", order = 5),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "per Palpation", order = 6),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
|
||||||
|
CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "vitalwerte_rr",
|
||||||
|
label = "RR (systolisch/diastolisch)",
|
||||||
|
order = 10,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "RR {0}/{1} mmHg",
|
||||||
|
fields = listOf(
|
||||||
|
InputFieldDef.Number(label = "RR systolisch", maxLength = 3),
|
||||||
|
InputFieldDef.Number(label = "RR diastolisch", maxLength = 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "vitalwerte_puls",
|
||||||
|
label = "Puls (BPM)",
|
||||||
|
order = 11,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Puls {0} BPM",
|
||||||
|
fields = listOf(InputFieldDef.Number(label = "Puls (BPM)", maxLength = 3)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "vitalwerte_blutzucker",
|
||||||
|
label = "Blutzucker (mg/dl)",
|
||||||
|
order = 12,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Blutzucker {0} mg/dl",
|
||||||
|
fields = listOf(InputFieldDef.Number(label = "Blutzucker (mg/dl)", maxLength = 3)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "vitalwerte_spo2",
|
||||||
|
label = "SpO2 (%)",
|
||||||
|
order = 13,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "SpO2 {0}%",
|
||||||
|
fields = listOf(InputFieldDef.Number(label = "SpO2 (%)", maxLength = 3)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "vitalwerte_temperatur",
|
||||||
|
label = "Temperatur (°C)",
|
||||||
|
order = 14,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Temp. {0} °C",
|
||||||
|
fields = listOf(InputFieldDef.Number(label = "Temperatur (°C)", maxLength = 4, allowDecimal = true)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "wundheilung_beeinflussende_faktoren",
|
||||||
|
title = "Wundheilung beeinflussende Faktoren",
|
||||||
|
order = 16,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "wbf_infektion_keimbesiedelung", label = "Infektion/Keimbesiedelung", order = 1),
|
||||||
|
CheckItemDefinition(id = "wbf_alter", label = "Alter", order = 2),
|
||||||
|
CheckItemDefinition(id = "wbf_diabetes_mellitus", label = "Diabetes mellitus", order = 3),
|
||||||
|
CheckItemDefinition(id = "wbf_geschwaechtes_immunsystem", label = "Geschwächtes Immunsystem", order = 4),
|
||||||
|
CheckItemDefinition(id = "wbf_ernaehrungszustand_dehydration", label = "Ernährungszustand/Dehydration", order = 5),
|
||||||
|
CheckItemDefinition(id = "wbf_medikamente", label = "Medikamente", order = 6),
|
||||||
|
CheckItemDefinition(id = "wbf_rauchen", label = "Rauchen", order = 7),
|
||||||
|
CheckItemDefinition(id = "wbf_adipositas", label = "Adipositas", order = 8),
|
||||||
|
CheckItemDefinition(id = "wbf_psych_stress_schlafmangel", label = "Psych. Stress/Schlafmangel", order = 9),
|
||||||
|
CheckItemDefinition(id = "wbf_mobilitaet", label = "Mobilität", order = 10),
|
||||||
|
CheckItemDefinition(id = "wbf_lage_der_wunde", label = "Lage der Wunde", order = 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "auswirkungen_alltag",
|
||||||
|
title = "Auswirkungen auf das alltägliche Leben",
|
||||||
|
order = 17,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 1),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_schmerz", label = "Schmerz", order = 2),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_eingeschraenkte_mobilitaet", label = "Eingeschränkte Mobilität", order = 3),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_schlafstoerung", label = "Schlafstörung", order = 4),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_koerperbildstoerung", label = "Körperbildstörung", order = 5),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_geruch", label = "Geruch", order = 6),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_sozialer_rueckzug", label = "Sozialer Rückzug", order = 7),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_zeitaufwand", label = "Zeitaufwand", order = 8),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_einschraenkung_kleidung_hygiene", label = "Einschränkung Kleidung/Hygiene", order = 9),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_abhaengigkeit_von_dritten", label = "Abhängigkeit von Dritten (Pflegedienst/Ärzte/Angehörige)", order = 10),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_angst", label = "Angst", order = 11),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_hobbyverlust", label = "Hobbyverlust", order = 12),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_berufliche_ausfaelle", label = "Berufliche Ausfälle", order = 13),
|
||||||
|
CheckItemDefinition(id = "auswirkungen_alltag_finanzielle_zusatzbelastung", label = "Finanzielle Zusatzbelastung", order = 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "bekannte_allergie",
|
||||||
|
title = "Bekannte Allergie",
|
||||||
|
order = 18,
|
||||||
|
items = listOf(
|
||||||
|
CheckItemDefinition(id = "bekannte_allergie_ja", label = "Ja", order = 1),
|
||||||
|
CheckItemDefinition(id = "bekannte_allergie_nein", label = "Nein", order = 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "beratung_erfolgte",
|
||||||
|
title = "Beratung erfolgte",
|
||||||
|
order = 19,
|
||||||
|
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 = 20,
|
||||||
|
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 = 21,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "empfohlene_wundversorgung",
|
||||||
|
title = "Empfohlene Wundversorgung",
|
||||||
|
order = 22,
|
||||||
|
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",
|
||||||
|
order = 6,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "Getränkte Kompresse für ca. {0} Min. auflegen",
|
||||||
|
fields = listOf(InputFieldDef.Choice(label = "Minuten", options = MINUTE_1_TO_15_OPTIONS)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_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_wundrandschutz_ja", label = "Ja", order = 15),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
|
||||||
|
label = "Wie oft pro Woche?",
|
||||||
|
order = 17,
|
||||||
|
inputSpec = InputSpec(
|
||||||
|
pdfTemplate = "{0}x pro Woche",
|
||||||
|
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, isStaticHint = true),
|
||||||
|
CheckItemDefinition(
|
||||||
|
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
||||||
|
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
||||||
|
order = 31,
|
||||||
|
isStaticHint = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "ziel",
|
||||||
|
title = "Ziel",
|
||||||
|
order = 23,
|
||||||
|
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),
|
||||||
|
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlockDefinition(
|
||||||
|
id = "kopie_nachrichtlich_an",
|
||||||
|
title = "Kopie nachrichtlich an",
|
||||||
|
order = 24,
|
||||||
|
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 = 25,
|
||||||
|
items = emptyList(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun findBlock(blockDefinitionId: String): BlockDefinition? =
|
fun findBlock(blockDefinitionId: String): BlockDefinition? =
|
||||||
@@ -64,10 +604,47 @@ data class BlockDefinition(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val order: Int,
|
val order: Int,
|
||||||
val items: List<CheckItemDefinition>,
|
val items: List<CheckItemDefinition>,
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte, generische
|
||||||
|
* Freitext-Option dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein
|
||||||
|
* Bestandteil von [items], da diese generische Freitext-Option kein fachlicher
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
|
||||||
|
*
|
||||||
|
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false, [inputSpec] = null).
|
||||||
|
*
|
||||||
|
* [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.
|
||||||
|
*
|
||||||
|
* [inputSpec] != null: dieser Eintrag besitzt statt eines einfachen Häkchens ein oder mehrere
|
||||||
|
* strukturierte Eingabefelder (Zahl oder Auswahlliste, z. B. "Von [ ] bis [ ] Uhr" oder
|
||||||
|
* "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
|
||||||
|
*
|
||||||
|
* [isFreeText], [isStaticHint] und [inputSpec] schließen sich fachlich gegenseitig aus (ein
|
||||||
|
* Eintrag ist entweder normale Checkbox, Freitext-Checkbox, Hinweistext oder strukturierte
|
||||||
|
* Eingabe) - es gibt aber bewusst keine Laufzeitprüfung dafür, um die Katalogdefinition einfach
|
||||||
|
* zu halten; mehrere davon gleichzeitig zu setzen ergibt schlicht keinen sinnvollen
|
||||||
|
* Katalogeintrag.
|
||||||
|
*/
|
||||||
data class CheckItemDefinition(
|
data class CheckItemDefinition(
|
||||||
val id: String,
|
val id: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val order: Int,
|
val order: Int,
|
||||||
|
val isFreeText: Boolean = false,
|
||||||
|
val isStaticHint: Boolean = false,
|
||||||
|
val inputSpec: InputSpec? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,24 @@ package de.medapp.wunddoku.domain.model
|
|||||||
* (CheckItemState.labelSnapshot in der Datenbank), NICHT aus einer Laufzeit-Auflösung des
|
* (CheckItemState.labelSnapshot in der Datenbank), NICHT aus einer Laufzeit-Auflösung des
|
||||||
* aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Das stellt sicher, dass
|
* aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Das stellt sicher, dass
|
||||||
* 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. Siehe Architektur-Dokument Abschnitt 2.4.
|
* dargestellt werden.
|
||||||
|
*
|
||||||
|
* [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
|
||||||
|
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`; zusätzlich kann der Katalog
|
||||||
|
* selbst beliebig viele weitere `isFreeText = true`-Optionen mit eigenem, fachlichem Label
|
||||||
|
* definieren (z. B. "Wundgrund", "Erscheinung", oder die vier Empfänger bei "Kopie
|
||||||
|
* 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]).
|
||||||
|
*
|
||||||
|
* [inputSpec]: nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens strukturierte
|
||||||
|
* Eingabefelder besitzt (Zahl/Auswahlliste) - roher, codierter String, s. [InputSpecCodec.decode].
|
||||||
|
* Die eingegebenen Werte liegen weiterhin in [freeTextValue] (mehrere Felder durch ein
|
||||||
|
* Trennzeichen kombiniert, s. [InputSpec.decodeValues]/[InputSpec.encodeValues]).
|
||||||
*/
|
*/
|
||||||
data class CheckItem(
|
data class CheckItem(
|
||||||
val checkItemStateId: String,
|
val checkItemStateId: String,
|
||||||
@@ -15,4 +32,8 @@ data class CheckItem(
|
|||||||
val label: String,
|
val label: String,
|
||||||
val order: Int,
|
val order: Int,
|
||||||
val checked: Boolean,
|
val checked: Boolean,
|
||||||
|
val isFreeText: Boolean = false,
|
||||||
|
val freeTextValue: String = "",
|
||||||
|
val isStaticHint: Boolean = false,
|
||||||
|
val inputSpec: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.time.Instant
|
|||||||
data class Documentation(
|
data class Documentation(
|
||||||
val id: String,
|
val id: String,
|
||||||
val patientId: String,
|
val patientId: String,
|
||||||
|
val patientSnapshot: PatientStammdatenSnapshot,
|
||||||
val status: DocumentationStatus,
|
val status: DocumentationStatus,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
|
|||||||
* Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht
|
* Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht
|
||||||
* aus einer Laufzeit-Auflösung des Katalogs.
|
* aus einer Laufzeit-Auflösung des Katalogs.
|
||||||
*
|
*
|
||||||
|
* Kein eigenes `freeText`-Feld mehr: Der Freitext eines Blocks ist eine der Einträge in [items]
|
||||||
|
* (dasjenige CheckItem mit `isFreeText = true`) - siehe [CheckItem].
|
||||||
|
*
|
||||||
* [isEdited] wird bewusst NICHT in der Datenbank gespeichert, sondern hier als abgeleitete
|
* [isEdited] wird bewusst NICHT in der Datenbank gespeichert, sondern hier als abgeleitete
|
||||||
* Eigenschaft berechnet ("computed property", kein eigenes Feld im Konstruktor). Das
|
* Eigenschaft berechnet. Das verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen
|
||||||
* verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen Inhalt abweichen kann.
|
* Inhalt abweichen kann. [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert.
|
||||||
* [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert – sowohl die UI
|
|
||||||
* (Fortschrittsanzeige) als auch der spätere PDF-Export verwenden ausschließlich diese
|
|
||||||
* Eigenschaft. Siehe Architektur-Dokument Abschnitt 2.3c.
|
|
||||||
*/
|
*/
|
||||||
data class DocumentationBlock(
|
data class DocumentationBlock(
|
||||||
val blockStateId: String,
|
val blockStateId: String,
|
||||||
@@ -20,7 +20,6 @@ data class DocumentationBlock(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val order: Int,
|
val order: Int,
|
||||||
val items: List<CheckItem>,
|
val items: List<CheckItem>,
|
||||||
val freeText: String,
|
|
||||||
) {
|
) {
|
||||||
val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this)
|
val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package de.medapp.wunddoku.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelnes Eingabefeld innerhalb eines strukturierten Katalog-Eintrags (s. [InputSpec]).
|
||||||
|
*
|
||||||
|
* Bewusst nur zwei Feldarten - genau das, was die Kundenvorgaben bisher benötigen:
|
||||||
|
* - [Number]: Zahleneingabe mit maximaler Zeichenzahl (z. B. Länge/Breite/Tiefe, Blutdruck)
|
||||||
|
* - [Choice]: Auswahl aus einer festen Liste (z. B. Uhrzeit-Position 1-12, Tiefe in 0,5-cm-Schritten)
|
||||||
|
*/
|
||||||
|
sealed interface InputFieldDef {
|
||||||
|
val label: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param maxLength maximale Zeichenzahl (bei [allowDecimal] inklusive des Dezimaltrennzeichens)
|
||||||
|
* @param allowDecimal erlaubt genau ein Dezimalkomma (Punkt wird beim Tippen zu Komma)
|
||||||
|
*/
|
||||||
|
data class Number(
|
||||||
|
override val label: String,
|
||||||
|
val maxLength: Int,
|
||||||
|
val allowDecimal: Boolean = false,
|
||||||
|
) : InputFieldDef
|
||||||
|
|
||||||
|
data class Choice(
|
||||||
|
override val label: String,
|
||||||
|
val options: List<String>,
|
||||||
|
) : InputFieldDef
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beschreibt einen Katalog-Eintrag, der statt eines einfachen Häkchens (oder Freitexts) ein oder
|
||||||
|
* mehrere strukturierte Eingabefelder besitzt - z. B. "Von [3] bis [5] Uhr" oder
|
||||||
|
* "Länge [ ] Breite [ ] Tiefe [ ]".
|
||||||
|
*
|
||||||
|
* Der Eintrag bleibt trotzdem eine ganz normale Checkbox-Zeile (s. CheckItem.checked): Sobald in
|
||||||
|
* einem Feld etwas eingegeben/gewählt wird, wird das Häkchen automatisch gesetzt (s. UI). Dadurch
|
||||||
|
* greifen BlockEditedPolicy und die "nur angehakte Elemente"-Regel im PDF-Export unverändert -
|
||||||
|
* es gibt keine zweite "bearbeitet"-Logik für diesen Eintragstyp.
|
||||||
|
*
|
||||||
|
* [pdfTemplate] bestimmt, wie der Eintrag im PDF ausgegeben wird; `{0}`, `{1}` ... werden durch die
|
||||||
|
* Werte der Felder (in Reihenfolge von [fields]) ersetzt, z. B. `"Von {0} bis {1} Uhr"`.
|
||||||
|
*
|
||||||
|
* Die Beschreibung wird beim Anlegen einer Dokumentation zusammen mit dem Eintrag gespeichert
|
||||||
|
* (Snapshot, s. [InputSpecCodec]) - spätere Katalogänderungen (andere Auswahlwerte, andere
|
||||||
|
* Textvorlage) verändern bestehende Dokumentationen dadurch nicht rückwirkend.
|
||||||
|
*/
|
||||||
|
data class InputSpec(
|
||||||
|
val pdfTemplate: String,
|
||||||
|
val fields: List<InputFieldDef>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
|
||||||
|
fun decodeValues(stored: String): List<String> {
|
||||||
|
val parts = if (stored.isEmpty()) emptyList() else stored.split(VALUE_SEPARATOR)
|
||||||
|
return List(fields.size) { index -> parts.getOrElse(index) { "" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fasst die Einzelwerte der Felder zu dem einen String zusammen, der in der Datenbank steht. */
|
||||||
|
fun encodeValues(values: List<String>): String = values.joinToString(VALUE_SEPARATOR.toString())
|
||||||
|
|
||||||
|
/** true, sobald mindestens ein Feld einen Wert enthält. */
|
||||||
|
fun hasAnyValue(stored: String): Boolean = decodeValues(stored).any { it.isNotBlank() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text für den PDF-Export: [pdfTemplate] mit den eingesetzten Feldwerten. Ein noch leeres
|
||||||
|
* Feld erscheint als "–", damit erkennbar bleibt, dass dort bewusst nichts gewählt wurde.
|
||||||
|
*/
|
||||||
|
fun formatForPdf(stored: String): String {
|
||||||
|
var result = pdfTemplate
|
||||||
|
decodeValues(stored).forEachIndexed { index, value ->
|
||||||
|
result = result.replace("{$index}", value.ifBlank { EMPTY_VALUE_PLACEHOLDER })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Trennzeichen zwischen den Werten mehrerer Felder (Steuerzeichen, kommt in Zahlen/Auswahlwerten nie vor). */
|
||||||
|
const val VALUE_SEPARATOR: Char = '\u001F'
|
||||||
|
private const val EMPTY_VALUE_PLACEHOLDER = "–"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (De-)Serialisierung von [InputSpec] in einen einzelnen String für die Datenbankspalte
|
||||||
|
* `check_item_states.inputSpec`. Bewusst ein schlichtes eigenes Format mit Steuerzeichen als
|
||||||
|
* Trennern statt JSON: reines Kotlin ohne Android-Klassen (jederzeit in normalen JUnit-Tests
|
||||||
|
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
|
||||||
|
* Textvorlagen und Auswahlwerten nicht vor.
|
||||||
|
*
|
||||||
|
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ...`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
|
||||||
|
* `C␟label␟option␝option␝...`.
|
||||||
|
*/
|
||||||
|
object InputSpecCodec {
|
||||||
|
private const val RECORD_SEPARATOR = '\u001E'
|
||||||
|
private const val UNIT_SEPARATOR = '\u001F'
|
||||||
|
private const val GROUP_SEPARATOR = '\u001D'
|
||||||
|
|
||||||
|
fun encode(spec: InputSpec): String {
|
||||||
|
val records = mutableListOf(spec.pdfTemplate)
|
||||||
|
for (field in spec.fields) {
|
||||||
|
records += when (field) {
|
||||||
|
is InputFieldDef.Number -> listOf(
|
||||||
|
"N",
|
||||||
|
field.label,
|
||||||
|
field.maxLength.toString(),
|
||||||
|
if (field.allowDecimal) "1" else "0",
|
||||||
|
).joinToString(UNIT_SEPARATOR.toString())
|
||||||
|
|
||||||
|
is InputFieldDef.Choice -> listOf(
|
||||||
|
"C",
|
||||||
|
field.label,
|
||||||
|
field.options.joinToString(GROUP_SEPARATOR.toString()),
|
||||||
|
).joinToString(UNIT_SEPARATOR.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records.joinToString(RECORD_SEPARATOR.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert `null` für einen leeren String (Eintrag ohne strukturierte Felder) oder bei nicht lesbaren Daten. */
|
||||||
|
fun decode(encoded: String): InputSpec? {
|
||||||
|
if (encoded.isEmpty()) return null
|
||||||
|
val records = encoded.split(RECORD_SEPARATOR)
|
||||||
|
if (records.size < 2) return null
|
||||||
|
|
||||||
|
val fields = records.drop(1).mapNotNull { record ->
|
||||||
|
val attributes = record.split(UNIT_SEPARATOR)
|
||||||
|
when (attributes.firstOrNull()) {
|
||||||
|
"N" -> {
|
||||||
|
val maxLength = attributes.getOrNull(2)?.toIntOrNull()
|
||||||
|
if (attributes.size >= 4 && maxLength != null) {
|
||||||
|
InputFieldDef.Number(
|
||||||
|
label = attributes[1],
|
||||||
|
maxLength = maxLength,
|
||||||
|
allowDecimal = attributes[3] == "1",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"C" -> if (attributes.size >= 3) {
|
||||||
|
InputFieldDef.Choice(
|
||||||
|
label = attributes[1],
|
||||||
|
options = attributes[2].split(GROUP_SEPARATOR),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fields.isEmpty()) return null
|
||||||
|
return InputSpec(pdfTemplate = records.first(), fields = fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,16 +9,20 @@ import java.time.LocalDate
|
|||||||
*
|
*
|
||||||
* Vor- und Nachname werden getrennt gespeichert statt als ein "name"-Feld: Das ermöglicht
|
* Vor- und Nachname werden getrennt gespeichert statt als ein "name"-Feld: Das ermöglicht
|
||||||
* separate Suche/Sortierung nach Nachnamen (in Kliniken/Pflege üblich, z. B. "Nachname,
|
* separate Suche/Sortierung nach Nachnamen (in Kliniken/Pflege üblich, z. B. "Nachname,
|
||||||
* Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen
|
* Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen.
|
||||||
* (z. B. Anrede + Nachname in Dokumenten), ohne den Namen nachträglich wieder aufteilen zu
|
*
|
||||||
* müssen.
|
* Die Adresse wird ebenso atomar erfasst ([street], [postalCode], [city]) statt als einzelnes
|
||||||
|
* Freitextfeld: ermöglicht z. B. spätere PLZ-basierte Auswertungen oder Validierung, ohne die
|
||||||
|
* Adresse nachträglich wieder zerlegen zu müssen.
|
||||||
*/
|
*/
|
||||||
data class Patient(
|
data class Patient(
|
||||||
val id: String,
|
val id: String,
|
||||||
val firstName: String,
|
val firstName: String,
|
||||||
val lastName: String,
|
val lastName: String,
|
||||||
val birthDate: LocalDate,
|
val birthDate: LocalDate,
|
||||||
val address: String,
|
val street: String,
|
||||||
|
val postalCode: String,
|
||||||
|
val city: String,
|
||||||
val phone1: String,
|
val phone1: String,
|
||||||
val phone2: String?,
|
val phone2: String?,
|
||||||
val phone3: String?,
|
val phone3: String?,
|
||||||
@@ -26,4 +30,5 @@ data class Patient(
|
|||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
) {
|
) {
|
||||||
val fullName: String get() = "$firstName $lastName"
|
val fullName: String get() = "$firstName $lastName"
|
||||||
|
val displayAddress: String get() = "$street, $postalCode $city"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import java.time.Instant
|
|||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schlanke Projektion für die Patientenliste (Architektur-Dokument Abschnitt 6), inklusive
|
* Schlanke Projektion für die Patientenliste (Aufgabenstellung Abschnitt 6), inklusive Datum
|
||||||
* Datum der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die
|
* der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die
|
||||||
* Listen-Query nicht implizit an eine bestimmte Detaildarstellung gekoppelt ist.
|
* Listen-Query nicht implizit an eine bestimmte Detaildarstellung gekoppelt ist.
|
||||||
*/
|
*/
|
||||||
data class PatientListItem(
|
data class PatientListItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
val birthDate: LocalDate,
|
val birthDate: LocalDate,
|
||||||
val address: String,
|
val street: String,
|
||||||
|
val postalCode: String,
|
||||||
|
val city: String,
|
||||||
val lastDocumentationAt: Instant?,
|
val lastDocumentationAt: Instant?,
|
||||||
)
|
) {
|
||||||
|
val displayAddress: String get() = "$street, $postalCode $city"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.medapp.wunddoku.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zum Erstellungszeitpunkt einer Wunddokumentation eingefrorener Stand der relevanten
|
||||||
|
* Patienten-Stammdaten (Name, Geburtsdatum, Adresse).
|
||||||
|
*
|
||||||
|
* Hintergrund: Vor dieser Änderung referenzierte [Documentation] den Patienten ausschließlich
|
||||||
|
* über [Documentation.patientId] und las Name/Adresse für die Anzeige live aus der aktuellen
|
||||||
|
* [Patient]-Tabelle. Das hätte bedeutet, dass eine spätere Änderung der Patienten-Stammdaten
|
||||||
|
* (z. B. Umzug) rückwirkend die Anzeige bereits bestehender, historischer Dokumentationen
|
||||||
|
* verändert hätte - fachlich nicht akzeptabel für ein Dokumentationssystem mit Patientendaten.
|
||||||
|
*
|
||||||
|
* Analog zum bereits bestehenden Snapshot-Konzept für Block-/Checkbox-Inhalte (titleSnapshot/
|
||||||
|
* labelSnapshot, siehe [DocumentationBlock]/[CheckItem]) wird dieser Snapshot beim Erstellen
|
||||||
|
* bzw. beim "Fortsetzen" einer Dokumentation jeweils FRISCH aus dem *aktuellen* Patientendatensatz
|
||||||
|
* gebildet (nicht von einer Vorgänger-Dokumentation kopiert) - eine neue Dokumentation soll den
|
||||||
|
* zum Zeitpunkt ihrer Erstellung aktuellen Stand des Patienten abbilden, während ältere
|
||||||
|
* Dokumentationen ihren eigenen, damaligen Stand unverändert behalten.
|
||||||
|
*/
|
||||||
|
data class PatientStammdatenSnapshot(
|
||||||
|
val firstName: String,
|
||||||
|
val lastName: String,
|
||||||
|
val birthDate: LocalDate,
|
||||||
|
val street: String,
|
||||||
|
val postalCode: String,
|
||||||
|
val city: String,
|
||||||
|
) {
|
||||||
|
val fullName: String get() = "$firstName $lastName"
|
||||||
|
val displayAddress: String get() = "$street, $postalCode $city"
|
||||||
|
}
|
||||||
@@ -5,15 +5,18 @@ import java.time.Instant
|
|||||||
/**
|
/**
|
||||||
* Eine digitale Unterschrift zu einer Wunddokumentation.
|
* Eine digitale Unterschrift zu einer Wunddokumentation.
|
||||||
*
|
*
|
||||||
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später
|
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit beliebig
|
||||||
* mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
|
* viele Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
|
||||||
* (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen
|
* (1:n-Beziehung, MEDIA-004). [fileName] verweist auf eine PNG-Datei im internen App-Speicher
|
||||||
* App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11.
|
* (kein BLOB in SQLite). [signerName] ist ein historischer Snapshot: spätere Änderungen an
|
||||||
|
* eventuellen Wundversorger-Stammdaten dürfen bereits gespeicherte Signaturen nicht rückwirkend
|
||||||
|
* verändern. [orderIndex] bestimmt die stabile Anzeige-/Export-Reihenfolge mehrerer Signaturen.
|
||||||
*/
|
*/
|
||||||
data class Signature(
|
data class Signature(
|
||||||
val id: String,
|
val id: String,
|
||||||
val documentationId: String,
|
val documentationId: String,
|
||||||
val signerName: String?,
|
val signerName: String,
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
|
val orderIndex: Int,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.medapp.wunddoku.domain.model
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein zuletzt verwendeter Wundversorger samt seiner aktuellsten Signatur - reines Komfort-Cache
|
||||||
|
* für Namensvorschläge und Vorbelegung im Signatur-Dialog, KEIN Benutzer-/Account-Objekt (s.
|
||||||
|
* [de.medapp.wunddoku.data.entity.WoundCarerEntity]). Hat keinerlei Einfluss auf bereits
|
||||||
|
* gespeicherte, historische [Signature]-Snapshots einzelner Dokumentationen.
|
||||||
|
*/
|
||||||
|
data class WoundCarer(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val signatureFileName: String,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
@@ -15,9 +15,23 @@ import de.medapp.wunddoku.domain.model.DocumentationBlock
|
|||||||
*/
|
*/
|
||||||
object BlockEditedPolicy {
|
object BlockEditedPolicy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Block gilt als bearbeitet, sobald mindestens eine seiner Optionen angehakt ist - das
|
||||||
|
* schließt die Freitext-Option (CheckItem.isFreeText = true) automatisch mit ein, da sie
|
||||||
|
* ebenfalls über [CheckItem.checked] gesetzt wird. Eine zusätzliche Prüfung auf vorhandenen
|
||||||
|
* Freitext-Inhalt ist seit der Umstellung des Freitexts auf eine Checkbox-Option nicht mehr
|
||||||
|
* 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
|
||||||
|
* 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 {
|
||||||
val hasFreeText = block.freeText.isNotBlank()
|
return block.items.any { it.checked && !it.isStaticHint }
|
||||||
val hasCheckedItem = block.items.any { it.checked }
|
|
||||||
return hasFreeText || hasCheckedItem
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package de.medapp.wunddoku.media
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine, von Android-Kontext (Storage/ContentResolver) unabhängige Bildverarbeitungs-
|
||||||
|
* Hilfsfunktionen - bewusst getrennt von [PhotoStorage], das für die Dateisystem-/
|
||||||
|
* ContentResolver-Koordination zuständig ist. Dadurch einzeln testbar (Unit-Test ohne
|
||||||
|
* Instrumentierung möglich, da nur `android.graphics`-Typen ohne Kontext verwendet werden).
|
||||||
|
*/
|
||||||
|
object ImageDownsampler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximale längste Kante gespeicherter Fotos in Pixeln.
|
||||||
|
*
|
||||||
|
* Begründung: 2000px ist deutlich mehr, als für die Bildschirmanzeige auf Smartphone/Tablet
|
||||||
|
* nötig ist, aber klein genug, um RAM-/Speicherverbrauch bei modernen 12-48-Megapixel-
|
||||||
|
* Smartphone-Fotos drastisch zu reduzieren (ein 48-MP-Foto hätte z. B. ~8000x6000px - mehr
|
||||||
|
* als 60x so viele Pixel wie 2000px auf der langen Kante). 2000px reicht außerdem für eine
|
||||||
|
* spätere PDF-Einbettung in guter Druck-/Bildschirmqualität, ohne Rohaufnahmen dauerhaft in
|
||||||
|
* voller Auflösung vorzuhalten.
|
||||||
|
*/
|
||||||
|
const val MAX_STORED_DIMENSION_PX = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zielgröße für Vorschaubilder in der Fotoübersicht der Dokumentationsbearbeitung. Deutlich
|
||||||
|
* kleiner als die gespeicherte Datei, damit beim Rendern eines Fotogitters nicht
|
||||||
|
* versehentlich für jede Kachel die volle 2000px-Version decodiert wird.
|
||||||
|
*/
|
||||||
|
const val THUMBNAIL_MAX_DIMENSION_PX = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPEG-Qualität beim Speichern (0-100).
|
||||||
|
*
|
||||||
|
* Begründung: 90 ist bewusst konservativ hoch gewählt. Die Bilder werden später Bestandteil
|
||||||
|
* medizinischer Dokumentations-PDFs (Wundränder, Verfärbungen, Größenverhältnisse müssen
|
||||||
|
* erkennbar bleiben) - eine aggressivere Kompression (z. B. 70-80) würde solche feinen
|
||||||
|
* Details sichtbar beeinträchtigen. 90 reduziert die Dateigröße gegenüber verlustfreier
|
||||||
|
* Speicherung trotzdem spürbar.
|
||||||
|
*/
|
||||||
|
const val JPEG_QUALITY = 90
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet den `inSampleSize`-Wert für `BitmapFactory.Options`, mit dem ein Bild beim
|
||||||
|
* Decodieren bereits speicherschonend heruntergerechnet gelesen wird (Zweierpotenz-
|
||||||
|
* Downsampling direkt im Decoder), statt es vollständig in Originalauflösung zu laden und
|
||||||
|
* erst danach zu skalieren - das ist der von Android empfohlene Weg, um
|
||||||
|
* OutOfMemoryError bei großen Fotos zu vermeiden.
|
||||||
|
*/
|
||||||
|
fun computeInSampleSize(sourceWidth: Int, sourceHeight: Int, targetMaxDimension: Int): Int {
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0 || targetMaxDimension <= 0) return 1
|
||||||
|
var inSampleSize = 1
|
||||||
|
val largestSourceDimension = maxOf(sourceWidth, sourceHeight)
|
||||||
|
if (largestSourceDimension > targetMaxDimension) {
|
||||||
|
val halfDimension = largestSourceDimension / 2
|
||||||
|
while (halfDimension / inSampleSize >= targetMaxDimension) {
|
||||||
|
inSampleSize *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inSampleSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet die EXIF-Ausrichtung [exifOrientation] auf [bitmap] an, sodass das Ergebnis bereits
|
||||||
|
* in der visuell korrekten Ausrichtung vorliegt (viele Smartphone-Kameras schreiben Fotos
|
||||||
|
* sensor-nativ und speichern die tatsächliche Ausrichtung nur als EXIF-Metadatum).
|
||||||
|
*
|
||||||
|
* Liefert bei `ORIENTATION_NORMAL`/`ORIENTATION_UNDEFINED` (inkl. fehlender EXIF-Daten,
|
||||||
|
* s. [PhotoStorage]) dieselbe Bitmap-Instanz unverändert zurück - kein unnötiges Neu-
|
||||||
|
* Rendern. Die aufrufende Stelle ist dafür verantwortlich, das Original zu recyceln, falls
|
||||||
|
* eine neue, rotierte Instanz zurückgegeben wurde und das Original nicht mehr gebraucht wird.
|
||||||
|
*/
|
||||||
|
fun applyExifOrientation(bitmap: Bitmap, exifOrientation: Int): Bitmap {
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (exifOrientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.postRotate(90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.postRotate(270f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
else -> return bitmap // NORMAL oder UNDEFINED - keine Korrektur nötig
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,65 +1,292 @@
|
|||||||
package de.medapp.wunddoku.media
|
package de.medapp.wunddoku.media
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verwaltet die physischen Bilddateien im internen App-Speicher (context.filesDir/photos/).
|
* Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation:
|
||||||
*
|
*
|
||||||
* WICHTIG (Phase-Hinweis): Diese Implementierung kopiert Bilder in Phase 2 zunächst
|
* ```
|
||||||
* unverändert (1:1-Kopie der ausgewählten Datei). Downsampling großer Smartphone-Fotos und
|
* filesDir/
|
||||||
* EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der
|
* └── photos/
|
||||||
* Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten,
|
* └── <documentationId>/
|
||||||
* dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern.
|
* ├── <uuid>.jpg
|
||||||
|
* └── ...
|
||||||
|
* ```
|
||||||
*
|
*
|
||||||
* Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung
|
* Ein eigenes Unterverzeichnis pro Dokumentation (statt eines einzigen flachen Ordners für alle
|
||||||
* einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21).
|
* Fotos) hat zwei konkrete Vorteile: (1) Löschen einer kompletten Dokumentation kann das
|
||||||
|
* gesamte Verzeichnis in einem Schritt entfernen ([deleteAll]), statt Einzeldateien
|
||||||
|
* durchzugehen; (2) beim Fortsetzen einer Dokumentation mit kopierten Fotos landen die Kopien
|
||||||
|
* physisch getrennt vom Original in einem eigenen Verzeichnis - Löschen eines Fotos in der
|
||||||
|
* einen Dokumentation kann das Foto der anderen dadurch strukturell nicht berühren.
|
||||||
|
*
|
||||||
|
* Dateinamen sind durchgehend zufällige UUIDs, unabhängig vom ursprünglichen Dateinamen des
|
||||||
|
* importierten Fotos - vermeidet Kollisionen und verhindert, dass ein möglicherweise
|
||||||
|
* sensibler/persönlicher Originaldateiname ins interne Dateisystem übernommen wird.
|
||||||
|
*
|
||||||
|
* Alle Ein-/Ausgabeoperationen laufen über [Dispatchers.IO], nie auf dem Hauptthread.
|
||||||
*/
|
*/
|
||||||
interface PhotoStorage {
|
interface PhotoStorage {
|
||||||
/** Kopiert das Bild von [sourceUri] in den internen Speicher und liefert den relativen Dateinamen. */
|
|
||||||
suspend fun copyFrom(sourceUri: Uri): String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei und liefert
|
* Importiert ein vom Android Photo Picker ausgewähltes Bild: liest es speicherschonend
|
||||||
* deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue
|
* (Downsampling auf [ImageDownsampler.MAX_STORED_DIMENSION_PX] längster Kante über
|
||||||
* DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das
|
* `BitmapFactory.Options.inSampleSize`, ohne das Originalbild vollständig in den Speicher
|
||||||
* Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen.
|
* zu laden), korrigiert die EXIF-Ausrichtung und speichert das Ergebnis als neue,
|
||||||
|
* eigenständige JPEG-Datei im internen Speicher. Liefert den relativen Dateinamen
|
||||||
|
* (ausschließlich der documentationId, s. [resolve]).
|
||||||
|
*
|
||||||
|
* Wirft eine Exception, wenn das Bild nicht gelesen/decodiert werden kann. Aufrufer sind
|
||||||
|
* dafür verantwortlich, das pro Bild abzufangen, damit ein einzelnes fehlerhaftes Bild bei
|
||||||
|
* Mehrfachauswahl nicht den gesamten Importvorgang abbricht.
|
||||||
*/
|
*/
|
||||||
suspend fun duplicate(fileName: String): String
|
suspend fun importFrom(documentationId: String, sourceUri: Uri): String
|
||||||
|
|
||||||
/** Löscht die physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
|
/**
|
||||||
suspend fun delete(fileName: String)
|
* Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei in einer
|
||||||
|
* ANDEREN Dokumentation ([targetDocumentationId]). Wird bei "Dokumentation fortsetzen" mit
|
||||||
|
* `copyPhotos = true` benötigt: Eine neue DB-Zeile, die auf dieselbe Datei wie das Original
|
||||||
|
* zeigt, wäre falsch - sonst würde Löschen des Fotos in einer Dokumentation es auch in der
|
||||||
|
* anderen entfernen.
|
||||||
|
*/
|
||||||
|
suspend fun duplicate(sourceDocumentationId: String, sourceFileName: String, targetDocumentationId: String): String
|
||||||
|
|
||||||
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen (z. B. zur Anzeige). */
|
/** Löscht eine einzelne physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
|
||||||
fun resolve(fileName: String): File
|
suspend fun delete(documentationId: String, fileName: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht das gesamte Fotoverzeichnis einer Dokumentation samt aller enthaltenen Dateien in
|
||||||
|
* einem Schritt. Verwendet, wenn die komplette Dokumentation gelöscht wird - Room-Cascade
|
||||||
|
* entfernt zwar die `PhotoEntity`-Zeilen, aber keine Dateien aus `filesDir`, das übernimmt
|
||||||
|
* ausschließlich diese Methode.
|
||||||
|
*/
|
||||||
|
suspend fun deleteAll(documentationId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. Wird von
|
||||||
|
* MEDIA-001/002 selbst nicht für den Zugriff von außen benötigt (Compose greift nicht
|
||||||
|
* direkt auf Dateien zu, s. [decodeThumbnail]), steht aber für die spätere PDF-
|
||||||
|
* Fotoanhang-Funktionalität bereit, ohne diese hier vorwegzunehmen.
|
||||||
|
*/
|
||||||
|
fun resolve(documentationId: String, fileName: String): File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodiert eine speicherschonende Vorschauversion (max. [maxDimensionPx] längste Kante)
|
||||||
|
* der gespeicherten Datei, statt die volle gespeicherte Auflösung zu laden - wichtig, damit
|
||||||
|
* ein Fotogitter mit vielen Bildern nicht für jede Kachel unnötig viel Speicher belegt.
|
||||||
|
* Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz, z. B.
|
||||||
|
* wenn eine Datei nachträglich außerhalb der App entfernt wurde).
|
||||||
|
*/
|
||||||
|
suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodiert die gespeicherte Datei proportional eingepasst in eine Box von höchstens
|
||||||
|
* [maxWidthPx] × [maxHeightPx] Pixeln, OHNE Hochskalieren über die native Auflösung hinaus
|
||||||
|
* (PDF-004: Fotoanhang). Läuft zweistufig:
|
||||||
|
*
|
||||||
|
* 1. Grobe, speicherschonende Decodierung über `BitmapFactory.Options.inSampleSize`
|
||||||
|
* (Zweierpotenz-Downsampling direkt im Decoder) - liefert ein Bitmap, das mindestens so
|
||||||
|
* groß wie die exakte Zielgröße ist, aber wegen der Zweierpotenz-Beschränkung von
|
||||||
|
* `inSampleSize` bei Quellbildern nahe der Zielgröße (z. B. nahe dem Import-Limit
|
||||||
|
* [ImageDownsampler.MAX_STORED_DIMENSION_PX]) oft noch keine ausreichende Reduktion
|
||||||
|
* bewirkt.
|
||||||
|
* 2. Exakte Skalierung dieses groben Zwischenergebnisses auf die tatsächliche Zielgröße
|
||||||
|
* (`Bitmap.createScaledBitmap`). Das Zwischenergebnis aus Schritt 1 wird direkt danach
|
||||||
|
* freigegeben (`recycle()`) - das ist unproblematisch, da es zu diesem Zeitpunkt noch
|
||||||
|
* nirgends (insbesondere nicht an `PdfDocument`) weitergereicht wurde. Nur das Ergebnis
|
||||||
|
* aus Schritt 2 wird von der aufrufenden Stelle tatsächlich in die PDF gezeichnet.
|
||||||
|
*
|
||||||
|
* Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz).
|
||||||
|
*/
|
||||||
|
suspend fun decodeFitted(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap?
|
||||||
}
|
}
|
||||||
|
|
||||||
class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||||
|
|
||||||
private val photosDir: File by lazy {
|
private val photosRootDir: File by lazy {
|
||||||
File(context.filesDir, "photos").apply { mkdirs() }
|
File(context.filesDir, "photos").apply { mkdirs() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun copyFrom(sourceUri: Uri): String {
|
private fun documentationDirPath(documentationId: String): File =
|
||||||
val fileName = "${UUID.randomUUID()}.jpg"
|
File(photosRootDir, documentationId)
|
||||||
val targetFile = File(photosDir, fileName)
|
|
||||||
context.contentResolver.openInputStream(sourceUri)?.use { input ->
|
private fun ensureDocumentationDir(documentationId: String): File =
|
||||||
targetFile.outputStream().use { output -> input.copyTo(output) }
|
documentationDirPath(documentationId).apply { mkdirs() }
|
||||||
} ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri")
|
|
||||||
return fileName
|
override fun resolve(documentationId: String, fileName: String): File =
|
||||||
|
File(documentationDirPath(documentationId), fileName)
|
||||||
|
|
||||||
|
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
|
||||||
|
// 1) Nur die Abmessungen ermitteln (inJustDecodeBounds), ohne Pixel zu laden.
|
||||||
|
//
|
||||||
|
// WICHTIG: BitmapFactory.decodeStream(...) liefert bei inJustDecodeBounds = true
|
||||||
|
// IMMER null zurück (es befüllt nur boundsOptions, gibt aber nie ein Bitmap zurück).
|
||||||
|
// Die Null-Prüfung muss sich deshalb auf den STREAM selbst beziehen, nicht auf das
|
||||||
|
// (stets null-wertige) Ergebnis von decodeStream() - sonst würde jeder Import-
|
||||||
|
// Versuch fälschlich als "Bild konnte nicht geöffnet werden" fehlschlagen, komplett
|
||||||
|
// unabhängig vom tatsächlichen Bild.
|
||||||
|
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
val boundsStream = resolver.openInputStream(sourceUri)
|
||||||
|
?: error("Bild konnte nicht geöffnet werden (Uri nicht mehr gültig?).")
|
||||||
|
boundsStream.use { input -> BitmapFactory.decodeStream(input, null, boundsOptions) }
|
||||||
|
|
||||||
|
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
|
||||||
|
error("Bildabmessungen konnten nicht ermittelt werden - vermutlich kein unterstütztes Bildformat.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val sampleSize = ImageDownsampler.computeInSampleSize(
|
||||||
|
boundsOptions.outWidth,
|
||||||
|
boundsOptions.outHeight,
|
||||||
|
ImageDownsampler.MAX_STORED_DIMENSION_PX,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2) Tatsächlich decodieren - bereits mit inSampleSize speicherschonend heruntergerechnet.
|
||||||
|
// Ein content://-InputStream lässt sich i. d. R. nicht zurückspulen, daher wird die
|
||||||
|
// Quelle für jeden Schritt separat neu geöffnet.
|
||||||
|
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
|
||||||
|
val decoded = resolver.openInputStream(sourceUri)?.use { input ->
|
||||||
|
BitmapFactory.decodeStream(input, null, decodeOptions)
|
||||||
|
} ?: error("Bild konnte nicht dekodiert werden.")
|
||||||
|
|
||||||
|
// 3) EXIF-Ausrichtung auslesen. Fehlende/nicht lesbare EXIF-Daten führen bewusst zu
|
||||||
|
// ORIENTATION_NORMAL (keine Korrektur) statt zu einem Fehler - ein Bild ohne EXIF
|
||||||
|
// ist kein Importfehler.
|
||||||
|
val orientation = resolver.openInputStream(sourceUri)?.use { input ->
|
||||||
|
runCatching {
|
||||||
|
ExifInterface(input).getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL,
|
||||||
|
)
|
||||||
|
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||||
|
} ?: ExifInterface.ORIENTATION_NORMAL
|
||||||
|
|
||||||
|
val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation)
|
||||||
|
|
||||||
|
// 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis
|
||||||
|
// speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen
|
||||||
|
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu
|
||||||
|
// übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener
|
||||||
|
// Nebeneffekt, keine bewusste Anforderung dieser Aufgabe.
|
||||||
|
val targetDir = ensureDocumentationDir(documentationId)
|
||||||
|
val fileName = "${UUID.randomUUID()}.jpg"
|
||||||
|
File(targetDir, fileName).outputStream().use { out ->
|
||||||
|
corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (corrected !== decoded) decoded.recycle()
|
||||||
|
corrected.recycle()
|
||||||
|
|
||||||
|
fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun duplicate(
|
||||||
|
sourceDocumentationId: String,
|
||||||
|
sourceFileName: String,
|
||||||
|
targetDocumentationId: String,
|
||||||
|
): String = withContext(Dispatchers.IO) {
|
||||||
|
val sourceFile = resolve(sourceDocumentationId, sourceFileName)
|
||||||
|
val targetFileName = "${UUID.randomUUID()}.jpg"
|
||||||
|
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
|
||||||
|
sourceFile.copyTo(targetFile, overwrite = false)
|
||||||
|
targetFileName
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun duplicate(fileName: String): String {
|
override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
|
||||||
val newFileName = "${UUID.randomUUID()}.jpg"
|
resolve(documentationId, fileName).delete()
|
||||||
resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false)
|
Unit
|
||||||
return newFileName
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun delete(fileName: String) {
|
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
|
||||||
resolve(fileName).delete()
|
documentationDirPath(documentationId).deleteRecursively()
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(fileName: String): File = File(photosDir, fileName)
|
override suspend fun decodeThumbnail(
|
||||||
|
documentationId: String,
|
||||||
|
fileName: String,
|
||||||
|
maxDimensionPx: Int,
|
||||||
|
): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
|
val file = resolve(documentationId, fileName)
|
||||||
|
if (!file.exists()) return@withContext null
|
||||||
|
runCatching {
|
||||||
|
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
|
||||||
|
val sampleSize = ImageDownsampler.computeInSampleSize(
|
||||||
|
boundsOptions.outWidth,
|
||||||
|
boundsOptions.outHeight,
|
||||||
|
maxDimensionPx,
|
||||||
|
)
|
||||||
|
BitmapFactory.decodeFile(
|
||||||
|
file.absolutePath,
|
||||||
|
BitmapFactory.Options().apply { inSampleSize = sampleSize },
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun decodeFitted(
|
||||||
|
documentationId: String,
|
||||||
|
fileName: String,
|
||||||
|
maxWidthPx: Int,
|
||||||
|
maxHeightPx: Int,
|
||||||
|
): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
|
val file = resolve(documentationId, fileName)
|
||||||
|
if (!file.exists()) return@withContext null
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
|
||||||
|
val sourceWidth = boundsOptions.outWidth
|
||||||
|
val sourceHeight = boundsOptions.outHeight
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0) return@runCatching null
|
||||||
|
|
||||||
|
// Zielgröße: proportionale Einpassung in (maxWidthPx x maxHeightPx), Faktor auf
|
||||||
|
// maximal 1f begrenzt - kein Hochskalieren über die native Auflösung hinaus.
|
||||||
|
val scale = minOf(
|
||||||
|
maxWidthPx.toFloat() / sourceWidth,
|
||||||
|
maxHeightPx.toFloat() / sourceHeight,
|
||||||
|
1f,
|
||||||
|
)
|
||||||
|
val targetWidth = (sourceWidth * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
val targetHeight = (sourceHeight * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
// Schritt 1: grobe, speicherschonende Decodierung über inSampleSize - liefert ein
|
||||||
|
// Bitmap, das mindestens so groß wie (targetWidth, targetHeight) ist.
|
||||||
|
val inSampleSize = ImageDownsampler.computeInSampleSize(
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
maxOf(targetWidth, targetHeight),
|
||||||
|
)
|
||||||
|
val decodeOptions = BitmapFactory.Options().apply {
|
||||||
|
this.inSampleSize = inSampleSize
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
val coarseBitmap = BitmapFactory.decodeFile(file.absolutePath, decodeOptions)
|
||||||
|
?: return@runCatching null
|
||||||
|
|
||||||
|
// Schritt 2: exakte Zielgröße. Nur nötig, wenn inSampleSize (Zweierpotenz) nicht
|
||||||
|
// bereits exakt getroffen hat - insbesondere bei Quellbildern nahe dem
|
||||||
|
// Import-Limit oft der Fall, da dort inSampleSize = 1 bleibt.
|
||||||
|
if (coarseBitmap.width == targetWidth && coarseBitmap.height == targetHeight) {
|
||||||
|
coarseBitmap
|
||||||
|
} else {
|
||||||
|
val precise = Bitmap.createScaledBitmap(coarseBitmap, targetWidth, targetHeight, true)
|
||||||
|
// Das grobe Zwischenergebnis wurde noch nirgends (insbesondere nicht an
|
||||||
|
// PdfDocument) weitergereicht - Freigabe an dieser Stelle ist unproblematisch.
|
||||||
|
if (precise !== coarseBitmap) coarseBitmap.recycle()
|
||||||
|
precise
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.pdf.PdfDocument
|
||||||
|
import android.text.TextPaint
|
||||||
|
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||||
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konkrete [DocumentationPdfExporter]-Implementierung auf Basis von `android.graphics.pdf.PdfDocument`.
|
||||||
|
*
|
||||||
|
* Checkboxen werden bewusst NICHT über Unicode-Zeichen (☒/☐) oder sonstige Font-Glyphen
|
||||||
|
* dargestellt, sondern ausschließlich über direkte Canvas-Vektorprimitive (`drawRect`/
|
||||||
|
* `drawLine`) gezeichnet - dadurch ist das PDF geräte- und schriftartunabhängig exakt
|
||||||
|
* reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert
|
||||||
|
* ist bzw. welche Unicode-Symbole sie abdeckt.
|
||||||
|
*
|
||||||
|
* PDF-004 (Fotoanhang) - wichtige, geprüfte Speicher-Einschränkung von `PdfDocument`: Jede
|
||||||
|
* `Page` wird intern über ein `SkPicture` AUFGEZEICHNET (verifiziert anhand des AOSP-
|
||||||
|
* Quellcodes, `frameworks/base/.../PdfDocument.cpp`: `startPage()` -> `mPicture->beginRecording()`,
|
||||||
|
* `finishPage()` -> nur `endRecording()`, tatsächliches PDF-Rendering inkl. Bild-Einbettung
|
||||||
|
* erst in `write()`/`writeTo()` über `canvas->drawPicture(*page->mPicture)`). Das bedeutet: JEDE
|
||||||
|
* über `Canvas.drawBitmap(...)` gezeichnete Bitmap muss bis zum Abschluss von `writeTo()`
|
||||||
|
* gültig (nicht recycelt) bleiben - ein `Bitmap.recycle()` direkt nach dem Zeichnen einer
|
||||||
|
* einzelnen Fotoseite ist NICHT sicher. Deshalb werden alle tatsächlich gezeichneten
|
||||||
|
* Foto-Bitmaps in [export] gesammelt und erst NACH `writeTo()` (im `finally`-Block)
|
||||||
|
* freigegeben - siehe [retainedPhotoBitmaps].
|
||||||
|
*
|
||||||
|
* Der einzige verbleibende Hebel gegen hohen Speicherverbrauch ist deshalb die Zielauflösung
|
||||||
|
* pro Foto, nicht der Recycling-Zeitpunkt: Zwischenergebnisse, die selbst nie an
|
||||||
|
* `PdfDocument`/`Canvas.drawBitmap` übergeben werden (grobe `inSampleSize`-Decodierung vor der
|
||||||
|
* exakten Zielgrößen-Skalierung, s. [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]),
|
||||||
|
* dürfen dagegen sofort freigegeben werden.
|
||||||
|
*
|
||||||
|
* Speicherort: `filesDir/pdf/<documentationId>/<Dateiname>.pdf` - ausschließlich intern, keine
|
||||||
|
* MediaStore-Nutzung, kein öffentlicher Speicher.
|
||||||
|
*/
|
||||||
|
class AndroidPdfDocumentExporter(
|
||||||
|
private val context: Context,
|
||||||
|
private val photoStorage: PhotoStorage,
|
||||||
|
private val signatureStorage: SignatureStorage,
|
||||||
|
) : DocumentationPdfExporter {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm.
|
||||||
|
private const val PAGE_WIDTH = 595
|
||||||
|
private const val PAGE_HEIGHT = 842
|
||||||
|
private const val MARGIN = 40f
|
||||||
|
|
||||||
|
private const val TITLE_TEXT_SIZE = 18f
|
||||||
|
private const val SUBTITLE_TEXT_SIZE = 12f
|
||||||
|
private const val HEADER_INFO_TEXT_SIZE = 11f
|
||||||
|
private const val BLOCK_TITLE_TEXT_SIZE = 14f
|
||||||
|
private const val ITEM_TEXT_SIZE = 11f
|
||||||
|
private const val FREE_TEXT_TEXT_SIZE = 10.5f
|
||||||
|
private const val FREE_TEXT_INDENT = 20f
|
||||||
|
|
||||||
|
// PDF-003: Signaturbereich. Fachlich handschriftliche Unterschriften, keine
|
||||||
|
// kryptographische PDF-Signatur (s. Aufgabenstellung Abschnitt 5).
|
||||||
|
private const val SIGNATURE_LABEL_TEXT_SIZE = 11f
|
||||||
|
private const val SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE = 11f
|
||||||
|
private const val SIGNATURE_PLACEHOLDER_TEXT_SIZE = 10.5f
|
||||||
|
|
||||||
|
/** Feste Zielbreite des Unterschriftsbilds im PDF (deutlich schmaler als die volle
|
||||||
|
* Inhaltsbreite - eine Unterschrift muss nicht seitenbreit dargestellt werden). Bei dem
|
||||||
|
* logischen 1000x400-Signaturformat (Seitenverhältnis 2,5:1, s. Zeichenfläche) ergibt
|
||||||
|
* das eine Bildhöhe von rund 88pt. */
|
||||||
|
private const val SIGNATURE_IMAGE_TARGET_WIDTH_PT = 220f
|
||||||
|
private const val SIGNATURE_LINE_WIDTH_PT = 220f
|
||||||
|
|
||||||
|
private const val SIGNATURE_SPACING_AFTER_LABEL = 6f
|
||||||
|
private const val SIGNATURE_SPACING_AFTER_IMAGE = 4f
|
||||||
|
private const val SIGNATURE_SPACING_AFTER_LINE = 2f
|
||||||
|
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
|
||||||
|
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
|
||||||
|
|
||||||
|
// Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
|
||||||
|
// der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
|
||||||
|
private const val ITEM_INDENT = 10f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
|
||||||
|
* Speicherverbrauch gewählt (freigegeben nach expliziter Prüfung der `PdfDocument`-
|
||||||
|
* Speichersemantik, s. Klassendokumentation): untere Grenze des ursprünglich
|
||||||
|
* vorgeschlagenen 150-200-DPI-Bereichs, da ALLE Foto-Bitmaps bis `writeTo()` gleichzeitig
|
||||||
|
* im Speicher gehalten werden müssen (keine echte "ein Bitmap zur Zeit"-Verarbeitung mit
|
||||||
|
* dieser PDF-Engine möglich) und `ARGB_8888` statt `RGB_565` verwendet wird (keine
|
||||||
|
* Farbtiefenreduktion bei medizinischen Wundfotos).
|
||||||
|
*/
|
||||||
|
private const val PHOTO_TARGET_DPI = 150
|
||||||
|
|
||||||
|
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
|
||||||
|
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
|
||||||
|
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
|
||||||
|
// eingelesen wird), nicht die exakte Zeichenposition (die hängt zusätzlich von der
|
||||||
|
// jeweils bereits gezeichneten Bildunterschrift ab und wird von PdfPageCursor selbst
|
||||||
|
// anhand des tatsächlich verbleibenden Platzes bestimmt).
|
||||||
|
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
|
||||||
|
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
|
||||||
|
|
||||||
|
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */
|
||||||
|
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt()
|
||||||
|
|
||||||
|
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */
|
||||||
|
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt()
|
||||||
|
|
||||||
|
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun export(model: PdfExportModel): PdfExportResult = withContext(Dispatchers.IO) {
|
||||||
|
val targetDir = File(File(context.filesDir, "pdf"), model.documentationId)
|
||||||
|
if (!targetDir.exists() && !targetDir.mkdirs()) {
|
||||||
|
return@withContext PdfExportResult.Failure(PdfExportError.DirectoryCreationFailed(targetDir.absolutePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetFile = File(targetDir, model.fileName)
|
||||||
|
// Erst in eine temporäre Datei schreiben und nur bei vollständigem Erfolg auf den
|
||||||
|
// endgültigen Dateinamen umbenennen - so kann am Zielpfad selbst nie eine halbfertige
|
||||||
|
// Datei liegen bleiben, auch nicht bei einem Fehler mitten im Schreibvorgang.
|
||||||
|
val tempFile = File(targetDir, "${model.fileName}.tmp")
|
||||||
|
|
||||||
|
val pdfDocument = PdfDocument()
|
||||||
|
// Sammelt AUSSCHLIESSLICH die Bitmaps, die tatsächlich per Canvas.drawBitmap() gezeichnet
|
||||||
|
// wurden (nicht die groben inSampleSize-Zwischenergebnisse, die bereits innerhalb von
|
||||||
|
// PhotoStorage.decodeFitted() freigegeben werden). Diese müssen laut der in der
|
||||||
|
// Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig
|
||||||
|
// bleiben und werden deshalb erst im finally-Block unten recycelt.
|
||||||
|
val retainedPhotoBitmaps = mutableListOf<Bitmap>()
|
||||||
|
// Signatur-Bitmaps unterliegen exakt derselben PdfDocument-Speichersemantik wie
|
||||||
|
// Foto-Bitmaps (s. Klassendokumentation) - eigene Liste statt Wiederverwendung von
|
||||||
|
// [retainedPhotoBitmaps], damit beide Kategorien unabhängig recycelt werden können.
|
||||||
|
val retainedSignatureBitmaps = mutableListOf<Bitmap>()
|
||||||
|
try {
|
||||||
|
val paints = ExportPaints()
|
||||||
|
val cursor = PdfPageCursor(
|
||||||
|
pdfDocument = pdfDocument,
|
||||||
|
pageWidth = PAGE_WIDTH,
|
||||||
|
pageHeight = PAGE_HEIGHT,
|
||||||
|
marginLeft = MARGIN,
|
||||||
|
marginRight = MARGIN,
|
||||||
|
marginTop = MARGIN,
|
||||||
|
marginBottom = MARGIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
drawHeader(cursor, model, paints)
|
||||||
|
for (block in model.blocks) {
|
||||||
|
drawBlock(cursor, block, paints)
|
||||||
|
}
|
||||||
|
// Reihenfolge PDF-003/Abschnitt 25: Dokumentationsblöcke -> Signaturen -> Fotoanhang.
|
||||||
|
if (model.signatures.isNotEmpty()) {
|
||||||
|
drawSignatures(cursor, model, paints, retainedSignatureBitmaps)
|
||||||
|
}
|
||||||
|
for ((index, photo) in model.photos.withIndex()) {
|
||||||
|
drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps)
|
||||||
|
}
|
||||||
|
cursor.finish()
|
||||||
|
|
||||||
|
FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) }
|
||||||
|
|
||||||
|
if (targetFile.exists()) targetFile.delete()
|
||||||
|
if (!tempFile.renameTo(targetFile)) {
|
||||||
|
tempFile.delete()
|
||||||
|
return@withContext PdfExportResult.Failure(
|
||||||
|
PdfExportError.WriteFailed("PDF konnte nicht am endgültigen Speicherort abgelegt werden.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfExportResult.Success(targetFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tempFile.delete()
|
||||||
|
PdfExportResult.Failure(
|
||||||
|
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
pdfDocument.close()
|
||||||
|
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
|
||||||
|
// bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist -
|
||||||
|
// in beiden Fällen werden diese Bitmaps nicht mehr gebraucht.
|
||||||
|
retainedPhotoBitmaps.forEach { runCatching { it.recycle() } }
|
||||||
|
retainedSignatureBitmaps.forEach { runCatching { it.recycle() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bündelt alle für den Export verwendeten Paints. */
|
||||||
|
private class ExportPaints {
|
||||||
|
val title = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = TITLE_TEXT_SIZE
|
||||||
|
isFakeBoldText = true
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val subtitle = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = SUBTITLE_TEXT_SIZE
|
||||||
|
color = Color.DKGRAY
|
||||||
|
}
|
||||||
|
val headerInfo = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = HEADER_INFO_TEXT_SIZE
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val blockTitle = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = BLOCK_TITLE_TEXT_SIZE
|
||||||
|
isFakeBoldText = true
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val item = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = ITEM_TEXT_SIZE
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val freeText = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = FREE_TEXT_TEXT_SIZE
|
||||||
|
color = Color.DKGRAY
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF-003: Signaturbereich.
|
||||||
|
val signatureLabel = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = SIGNATURE_LABEL_TEXT_SIZE
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val signatureNameUnderLine = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE
|
||||||
|
color = Color.BLACK
|
||||||
|
}
|
||||||
|
val signaturePlaceholder = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
textSize = SIGNATURE_PLACEHOLDER_TEXT_SIZE
|
||||||
|
color = Color.DKGRAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
|
||||||
|
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
|
||||||
|
val photo = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
isFilterBitmap = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawHeader(cursor: PdfPageCursor, model: PdfExportModel, paints: ExportPaints) {
|
||||||
|
cursor.drawText("VulneraDoc", paints.title)
|
||||||
|
cursor.drawText("Wunddokumentation", paints.subtitle, spacingAfter = 8f)
|
||||||
|
|
||||||
|
val infoLines = listOf(
|
||||||
|
"Patient: ${model.patientFullName}",
|
||||||
|
"Geburtsdatum: ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}",
|
||||||
|
"Adresse: ${model.patientDisplayAddress}",
|
||||||
|
"Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}",
|
||||||
|
)
|
||||||
|
for ((index, line) in infoLines.withIndex()) {
|
||||||
|
val spacing = if (index == infoLines.lastIndex) 12f else 2f
|
||||||
|
cursor.drawText(line, paints.headerInfo, spacingAfter = spacing)
|
||||||
|
}
|
||||||
|
cursor.drawDivider(spacingAfter = 12f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
|
||||||
|
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f)
|
||||||
|
|
||||||
|
for (item in block.items) {
|
||||||
|
drawItem(cursor, item, paints)
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Änderungswunsch: KEIN Checkbox-Symbol mehr im PDF. Da [PdfExportBlock.items] bereits nur
|
||||||
|
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
|
||||||
|
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
|
||||||
|
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
|
||||||
|
// Änderungswunsch: strukturierte Eingabefelder (Zahl/Auswahlliste, z. B. "Von ... bis
|
||||||
|
// ... Uhr") werden als EIN fertig formatierter Satz ausgegeben (s.
|
||||||
|
// InputSpec.formatForPdf) - kein Label+Doppelpunkt+separate Zusatzzeile wie bei
|
||||||
|
// Freitext, da die Textvorlage im Katalog bereits den vollständigen, lesbaren Satz
|
||||||
|
// vorgibt.
|
||||||
|
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
|
||||||
|
if (decodedInputSpec != null) {
|
||||||
|
cursor.drawText(
|
||||||
|
text = decodedInputSpec.formatForPdf(item.freeTextValue),
|
||||||
|
paint = paints.item,
|
||||||
|
indent = ITEM_INDENT,
|
||||||
|
spacingAfter = 3f,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
|
||||||
|
val labelText = if (item.isFreeText) "${item.label}:" else item.label
|
||||||
|
|
||||||
|
cursor.drawText(
|
||||||
|
text = labelText,
|
||||||
|
paint = paints.item,
|
||||||
|
indent = ITEM_INDENT,
|
||||||
|
spacingAfter = 3f,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
|
||||||
|
// leerer Freitext erzeugt keinen leeren Bereich im PDF.
|
||||||
|
if (item.isFreeText && item.freeTextValue.isNotBlank()) {
|
||||||
|
cursor.drawText(
|
||||||
|
text = item.freeTextValue,
|
||||||
|
paint = paints.freeText,
|
||||||
|
indent = FREE_TEXT_INDENT,
|
||||||
|
spacingAfter = 6f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
|
||||||
|
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
|
||||||
|
* nach orderIndex sortiert, s. [PdfExportModel.signatures]). Anders als der Fotoanhang
|
||||||
|
* beginnt dieser Abschnitt NICHT zwingend auf einer neuen Seite - er schließt sich direkt an
|
||||||
|
* die zuletzt gezeichneten Dokumentationsblöcke an, sofern noch Platz vorhanden ist
|
||||||
|
* (Aufgabenstellung Abschnitt 25/28).
|
||||||
|
*/
|
||||||
|
private suspend fun drawSignatures(
|
||||||
|
cursor: PdfPageCursor,
|
||||||
|
model: PdfExportModel,
|
||||||
|
paints: ExportPaints,
|
||||||
|
retainedSignatureBitmaps: MutableList<Bitmap>,
|
||||||
|
) {
|
||||||
|
cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
|
||||||
|
for (signature in model.signatures) {
|
||||||
|
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet EINEN Signaturblock (Wundversorgername, Bild bzw. neutraler Platzhalter bei
|
||||||
|
* beschädigter/fehlender Datei, Unterschriftslinie, ausgeschriebener Name darunter) als
|
||||||
|
* unteilbare Einheit: Die Gesamthöhe wird VORAB berechnet und über
|
||||||
|
* [PdfPageCursor.ensureBlockSpace] reserviert, BEVOR die erste Teilzeichnung beginnt - reicht
|
||||||
|
* der Platz auf der aktuellen Seite nicht, wandert der komplette Block auf die nächste Seite,
|
||||||
|
* nie nur ein Teil davon (Aufgabenstellung Abschnitt 28).
|
||||||
|
*
|
||||||
|
* Bitmap-Lebensdauer: Die dekodierte Signatur-Bitmap wird - genau wie Foto-Bitmaps in
|
||||||
|
* [drawPhotoPage] - in [retainedSignatureBitmaps] gesammelt und erst nach `writeTo()`
|
||||||
|
* recycelt (s. Klassendokumentation zur PdfDocument-Speichersemantik).
|
||||||
|
*/
|
||||||
|
private suspend fun drawSignatureBlock(
|
||||||
|
cursor: PdfPageCursor,
|
||||||
|
model: PdfExportModel,
|
||||||
|
signature: PdfExportSignature,
|
||||||
|
paints: ExportPaints,
|
||||||
|
retainedSignatureBitmaps: MutableList<Bitmap>,
|
||||||
|
) {
|
||||||
|
val labelText = "Wundversorger: ${signature.signerName}"
|
||||||
|
val nameText = signature.signerName
|
||||||
|
|
||||||
|
// Fehlende/beschädigte Signaturdatei führt NICHT zum Absturz des gesamten Exports -
|
||||||
|
// andere Signaturen und Fotos werden trotzdem exportiert (Aufgabenstellung Abschnitt 31).
|
||||||
|
// Bewusst OHNE Dateipfad/technischen Fehlertext im Platzhalter.
|
||||||
|
val bitmap = runCatching {
|
||||||
|
signatureStorage.decode(documentationId = model.documentationId, fileName = signature.fileName)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
val labelHeight = cursor.measureTextHeight(labelText, paints.signatureLabel)
|
||||||
|
val imageOrPlaceholderHeight = if (bitmap != null) {
|
||||||
|
(SIGNATURE_IMAGE_TARGET_WIDTH_PT.coerceAtMost(cursor.contentWidth.toFloat())) *
|
||||||
|
bitmap.height / bitmap.width
|
||||||
|
} else {
|
||||||
|
cursor.measureTextHeight("Signatur konnte nicht geladen werden.", paints.signaturePlaceholder)
|
||||||
|
}
|
||||||
|
val nameHeight = cursor.measureTextHeight(nameText, paints.signatureNameUnderLine)
|
||||||
|
|
||||||
|
val totalBlockHeight = labelHeight + SIGNATURE_SPACING_AFTER_LABEL +
|
||||||
|
imageOrPlaceholderHeight + SIGNATURE_SPACING_AFTER_IMAGE +
|
||||||
|
1f + SIGNATURE_SPACING_AFTER_LINE + // Linie selbst ist ca. 1pt "hoch"
|
||||||
|
nameHeight + SIGNATURE_SPACING_AFTER_BLOCK
|
||||||
|
|
||||||
|
// Reserviert die GESAMTE Blockhöhe in einem Rutsch, bevor überhaupt mit dem Zeichnen
|
||||||
|
// begonnen wird - das ist der eigentliche Mechanismus hinter "atomarer Block" (s.
|
||||||
|
// Methodenkommentar oben und PdfPageCursor.ensureBlockSpace).
|
||||||
|
cursor.ensureBlockSpace(totalBlockHeight)
|
||||||
|
|
||||||
|
cursor.drawText(labelText, paints.signatureLabel, spacingAfter = SIGNATURE_SPACING_AFTER_LABEL)
|
||||||
|
|
||||||
|
if (bitmap != null) {
|
||||||
|
retainedSignatureBitmaps.add(bitmap)
|
||||||
|
cursor.drawBitmapFixedWidth(
|
||||||
|
bitmap = bitmap,
|
||||||
|
targetWidth = SIGNATURE_IMAGE_TARGET_WIDTH_PT,
|
||||||
|
paint = paints.photo,
|
||||||
|
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cursor.drawText(
|
||||||
|
"Signatur konnte nicht geladen werden.",
|
||||||
|
paints.signaturePlaceholder,
|
||||||
|
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.drawShortLine(SIGNATURE_LINE_WIDTH_PT, spacingAfter = SIGNATURE_SPACING_AFTER_LINE)
|
||||||
|
cursor.drawText(nameText, paints.signatureNameUnderLine, spacingAfter = SIGNATURE_SPACING_AFTER_BLOCK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet den Fotoanhang: jedes Foto auf einer eigenen, neu begonnenen Seite (PDF-004).
|
||||||
|
* Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt
|
||||||
|
* zusätzlich eine neutrale Nummerierung ("Foto N von M") - keine Patientendaten oder
|
||||||
|
* ursprünglichen Dateinamen als Beschriftung.
|
||||||
|
*
|
||||||
|
* Erfolgreich decodierte Bitmaps werden in [retainedPhotoBitmaps] gesammelt (s. Begründung
|
||||||
|
* in der Klassendokumentation zur PdfDocument-Speichersemantik) - der Aufrufer ist dafür
|
||||||
|
* verantwortlich, diese erst NACH `writeTo()` freizugeben.
|
||||||
|
*/
|
||||||
|
private suspend fun drawPhotoPage(
|
||||||
|
cursor: PdfPageCursor,
|
||||||
|
model: PdfExportModel,
|
||||||
|
photo: PdfExportPhoto,
|
||||||
|
index: Int,
|
||||||
|
paints: ExportPaints,
|
||||||
|
retainedPhotoBitmaps: MutableList<Bitmap>,
|
||||||
|
) {
|
||||||
|
cursor.forceNewPage()
|
||||||
|
if (index == 0) {
|
||||||
|
cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f)
|
||||||
|
}
|
||||||
|
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
|
||||||
|
|
||||||
|
val bitmap = runCatching {
|
||||||
|
photoStorage.decodeFitted(
|
||||||
|
documentationId = model.documentationId,
|
||||||
|
fileName = photo.fileName,
|
||||||
|
maxWidthPx = PHOTO_MAX_WIDTH_PX,
|
||||||
|
maxHeightPx = PHOTO_MAX_HEIGHT_PX,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (bitmap != null) {
|
||||||
|
retainedPhotoBitmaps.add(bitmap)
|
||||||
|
cursor.drawBitmapFitted(bitmap, paints.photo)
|
||||||
|
} else {
|
||||||
|
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
|
||||||
|
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
|
||||||
|
// bleibt, dass an dieser Stelle ursprünglich ein Foto zur Dokumentation gehörte.
|
||||||
|
// Bewusst OHNE Dateipfad/technischen Fehlertext.
|
||||||
|
cursor.drawText("Foto konnte nicht geladen werden.", paints.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt aus einem [PdfExportModel] eine PDF-Datei im internen App-Speicher.
|
||||||
|
*
|
||||||
|
* Bewusst vollständig unabhängig von Compose, Navigation, ViewModels und rohen Room-Entities -
|
||||||
|
* kennt ausschließlich diese schlanke, layoutorientierte Export-Datenstruktur. Als Interface
|
||||||
|
* mit separater Implementierung ([AndroidPdfDocumentExporter]) gehalten, damit später eine
|
||||||
|
* PDF-Engine mit Verschlüsselungs-/Signaturunterstützung ergänzt werden kann (siehe
|
||||||
|
* Aufgabenstellung "Schreibgeschützt"), ohne Repository/ViewModel/UI zu berühren.
|
||||||
|
*/
|
||||||
|
interface DocumentationPdfExporter {
|
||||||
|
suspend fun export(model: PdfExportModel): PdfExportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klarer Erfolg-/Fehlerpfad statt roher, bis in die UI durchgereichter Exceptions. */
|
||||||
|
sealed interface PdfExportResult {
|
||||||
|
data class Success(val file: File) : PdfExportResult
|
||||||
|
data class Failure(val error: PdfExportError) : PdfExportResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fachliche Fehlerkategorien für den PDF-Export.
|
||||||
|
*
|
||||||
|
* [DocumentationNotFound] wird NICHT vom Exporter selbst geworfen (der kennt keine
|
||||||
|
* documentationId, nur das fertige [PdfExportModel]), sondern vom aufrufenden Repository, das
|
||||||
|
* die Dokumentation vorher lädt - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.exportPdf].
|
||||||
|
*
|
||||||
|
* Ein eigener Fehlerfall für "Patientendaten fehlen unerwartet" existiert bewusst NICHT: Da
|
||||||
|
* [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot] im Domain-Modell
|
||||||
|
* nicht-nullable ist, macht der Kotlin-Typ diesen Fall bereits zur Kompilierzeit unmöglich - ein
|
||||||
|
* zusätzlicher Laufzeit-Check wäre toter Code.
|
||||||
|
*/
|
||||||
|
sealed interface PdfExportError {
|
||||||
|
data object DocumentationNotFound : PdfExportError
|
||||||
|
data class DirectoryCreationFailed(val path: String) : PdfExportError
|
||||||
|
data class WriteFailed(val message: String) : PdfExportError
|
||||||
|
data class Unexpected(val message: String) : PdfExportError
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
|
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt ein [Documentation]-Domain-Modell in das layoutfertige [PdfExportModel] um.
|
||||||
|
*
|
||||||
|
* Verwendet für die Filterung ausschließlich die bestehende [BlockEditedPolicy] (keine eigene,
|
||||||
|
* abweichende Definition von "bearbeitet") und ausschließlich die historischen Snapshot-Felder
|
||||||
|
* (titleSnapshot/labelSnapshot bzw. deren bereits gemappte Domain-Entsprechungen
|
||||||
|
* [de.medapp.wunddoku.domain.model.DocumentationBlock.title]/[de.medapp.wunddoku.domain.model.CheckItem.label]) -
|
||||||
|
* niemals den aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Ebenso werden
|
||||||
|
* ausschließlich [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot]-Daten
|
||||||
|
* verwendet, nicht der aktuelle Patientendatensatz - eine spätere Änderung der Patienten-
|
||||||
|
* Stammdaten darf ein bereits erzeugtes PDF nicht rückwirkend verändern.
|
||||||
|
*/
|
||||||
|
object DocumentationToPdfExportModelMapper {
|
||||||
|
|
||||||
|
fun map(documentation: Documentation): PdfExportModel {
|
||||||
|
val createdAtDate = documentation.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
val snapshot = documentation.patientSnapshot
|
||||||
|
|
||||||
|
val editedBlocks = documentation.blocks
|
||||||
|
.filter { BlockEditedPolicy.isEdited(it) }
|
||||||
|
.sortedBy { it.order }
|
||||||
|
.map { block ->
|
||||||
|
PdfExportBlock(
|
||||||
|
title = block.title,
|
||||||
|
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
|
||||||
|
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
|
||||||
|
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
|
||||||
|
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
|
||||||
|
items = block.items
|
||||||
|
.filter { it.checked }
|
||||||
|
.sortedBy { it.order }
|
||||||
|
.map { item ->
|
||||||
|
PdfExportItem(
|
||||||
|
label = item.label,
|
||||||
|
isFreeText = item.isFreeText,
|
||||||
|
freeTextValue = item.freeTextValue,
|
||||||
|
inputSpec = item.inputSpec,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileName = PdfFileNameGenerator.generate(
|
||||||
|
createdAt = createdAtDate,
|
||||||
|
firstName = snapshot.firstName,
|
||||||
|
lastName = snapshot.lastName,
|
||||||
|
birthDate = snapshot.birthDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Explizit erneut nach orderIndex sortiert (nicht implizit auf die bereits im
|
||||||
|
// Domain-Modell vorhandene Reihenfolge verlassen) - dieselbe defensive Konsistenz wie
|
||||||
|
// bei blocks/items oben, gemäß Vorgabe "nicht Dateiname/UUID/createdAt/Dateisystem-
|
||||||
|
// Reihenfolge verwenden".
|
||||||
|
val photos = documentation.photos
|
||||||
|
.sortedBy { it.orderIndex }
|
||||||
|
.map { photo -> PdfExportPhoto(fileName = photo.fileName) }
|
||||||
|
|
||||||
|
// Explizit erneut nach orderIndex sortiert (dieselbe defensive Konsistenz wie bei
|
||||||
|
// blocks/items/photos oben) - alle gespeicherten Signaturen werden exportiert, es gibt
|
||||||
|
// keine feste Obergrenze und kein `signatures.first()` (Aufgabenstellung Abschnitt 29).
|
||||||
|
val signatures = documentation.signatures
|
||||||
|
.sortedBy { it.orderIndex }
|
||||||
|
.map { signature -> PdfExportSignature(signerName = signature.signerName, fileName = signature.fileName) }
|
||||||
|
|
||||||
|
return PdfExportModel(
|
||||||
|
documentationId = documentation.id,
|
||||||
|
fileName = fileName,
|
||||||
|
patientFullName = snapshot.fullName,
|
||||||
|
patientBirthDate = snapshot.birthDate,
|
||||||
|
patientDisplayAddress = snapshot.displayAddress,
|
||||||
|
createdAt = createdAtDate,
|
||||||
|
blocks = editedBlocks,
|
||||||
|
signatures = signatures,
|
||||||
|
photos = photos,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flache, ausschließlich layoutrelevante Export-Datenstruktur für [DocumentationPdfExporter].
|
||||||
|
*
|
||||||
|
* Bewusst getrennt vom Domain-Modell [de.medapp.wunddoku.domain.model.Documentation]: Der
|
||||||
|
* Exporter soll weder Room-Entities noch domänenspezifische Feinheiten (Snapshot-Mechanik,
|
||||||
|
* IDs, Status etc.) kennen müssen, sondern ausschließlich das, was tatsächlich auf die Seite
|
||||||
|
* gezeichnet wird. Die Umwandlung übernimmt [DocumentationToPdfExportModelMapper].
|
||||||
|
*/
|
||||||
|
data class PdfExportModel(
|
||||||
|
val documentationId: String,
|
||||||
|
val fileName: String,
|
||||||
|
val patientFullName: String,
|
||||||
|
val patientBirthDate: LocalDate,
|
||||||
|
val patientDisplayAddress: String,
|
||||||
|
val createdAt: LocalDate,
|
||||||
|
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
|
||||||
|
val blocks: List<PdfExportBlock>,
|
||||||
|
/** Bereits sortiert nach orderIndex (PDF-003: Unterschriften). Reihenfolge im fertigen PDF:
|
||||||
|
* Dokumentationsblöcke -> Signaturen -> Fotoanhang. */
|
||||||
|
val signatures: List<PdfExportSignature>,
|
||||||
|
/** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */
|
||||||
|
val photos: List<PdfExportPhoto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PdfExportBlock(
|
||||||
|
val title: String,
|
||||||
|
/** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
|
||||||
|
* (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
|
||||||
|
val items: List<PdfExportItem>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
|
||||||
|
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter.
|
||||||
|
*
|
||||||
|
* [inputSpec]: nicht-leer, wenn dieser Eintrag strukturierte Eingabefelder besitzt (s.
|
||||||
|
* [de.medapp.wunddoku.domain.model.InputSpec]) - der Exporter formatiert in diesem Fall
|
||||||
|
* [freeTextValue] über [de.medapp.wunddoku.domain.model.InputSpec.formatForPdf] statt es als
|
||||||
|
* einfachen Zusatztext unter [label] auszugeben. */
|
||||||
|
data class PdfExportItem(
|
||||||
|
val label: String,
|
||||||
|
val isFreeText: Boolean,
|
||||||
|
val freeTextValue: String,
|
||||||
|
val inputSpec: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] -
|
||||||
|
* `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über
|
||||||
|
* [de.medapp.wunddoku.media.PhotoStorage] die intern gespeicherte Datei aufzulösen).
|
||||||
|
*/
|
||||||
|
data class PdfExportPhoto(
|
||||||
|
val fileName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimale, für den Signaturbereich benötigte Referenz (PDF-003). [signerName] ist der
|
||||||
|
* historische Snapshot zum Zeitpunkt der Unterschrift - nicht der aktuelle Stand eventueller
|
||||||
|
* Wundversorger-Stammdaten. [fileName] wird analog [PdfExportPhoto] über
|
||||||
|
* [de.medapp.wunddoku.signature.SignatureStorage] aufgelöst (`documentationId` steht bereits am
|
||||||
|
* [PdfExportModel] zur Verfügung).
|
||||||
|
*/
|
||||||
|
data class PdfExportSignature(
|
||||||
|
val signerName: String,
|
||||||
|
val fileName: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale, reine (keine Android-Abhängigkeit, ohne Instrumentierung testbar) Erzeugung des
|
||||||
|
* vereinbarten PDF-Dateinamensschemas:
|
||||||
|
*
|
||||||
|
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`, z. B.
|
||||||
|
* `2026-08-23_Max_Mustermann_1980-03-14.pdf`
|
||||||
|
*
|
||||||
|
* Alle Bestandteile werden dateisystemkompatibel normalisiert (keine Pfadseparatoren, keine
|
||||||
|
* ungültigen Zeichen, deutsche Umlaute transliteriert statt einfach entfernt).
|
||||||
|
*/
|
||||||
|
object PdfFileNameGenerator {
|
||||||
|
|
||||||
|
fun generate(createdAt: LocalDate, firstName: String, lastName: String, birthDate: LocalDate): String {
|
||||||
|
val datePart = createdAt.toString() // LocalDate.toString() liefert ISO-8601 (yyyy-MM-dd)
|
||||||
|
val birthPart = birthDate.toString()
|
||||||
|
val firstNamePart = normalizeNameComponent(firstName)
|
||||||
|
val lastNamePart = normalizeNameComponent(lastName)
|
||||||
|
return "${datePart}_${firstNamePart}_${lastNamePart}_${birthPart}.pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisiert einen einzelnen Namensbestandteil:
|
||||||
|
* 1. Deutsche Umlaute/ß werden transliteriert (ä→ae usw.), nicht einfach entfernt - sonst
|
||||||
|
* würden z. B. "Müller" und "Mller" im Dateinamen ununterscheidbar.
|
||||||
|
* 2. Verbleibende Diakritika (andere Sprachen) werden über Unicode-Normalisierung entfernt.
|
||||||
|
* 3. Leerzeichen werden zu Unterstrichen, alles außerhalb `[A-Za-z0-9_-]` wird entfernt -
|
||||||
|
* schließt insbesondere Pfadseparatoren (`/`, `\`) und sonstige für Dateinamen
|
||||||
|
* problematische Zeichen sicher aus.
|
||||||
|
* 4. Mehrfache Unterstriche werden zusammengefasst, führende/nachgestellte entfernt.
|
||||||
|
*/
|
||||||
|
private fun normalizeNameComponent(input: String): String {
|
||||||
|
val transliterated = transliterateGerman(input)
|
||||||
|
val withoutDiacritics = Normalizer.normalize(transliterated, Normalizer.Form.NFD)
|
||||||
|
.replace(Regex("\\p{M}+"), "")
|
||||||
|
val safe = withoutDiacritics
|
||||||
|
.replace(Regex("\\s+"), "_")
|
||||||
|
.replace(Regex("[^A-Za-z0-9_-]"), "")
|
||||||
|
.replace(Regex("_+"), "_")
|
||||||
|
.trim('_', '-')
|
||||||
|
return safe.ifBlank { "Unbekannt" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transliterateGerman(input: String): String = input
|
||||||
|
.replace("ä", "ae").replace("Ä", "Ae")
|
||||||
|
.replace("ö", "oe").replace("Ö", "Oe")
|
||||||
|
.replace("ü", "ue").replace("Ü", "Ue")
|
||||||
|
.replace("ß", "ss")
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,89 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet eine bereits erzeugte PDF-Datei im auf dem Gerät verfügbaren Standard-PDF-Reader
|
||||||
|
* (PDF-005). Bewusst getrennt von [DocumentationPdfExporter]: PDF-Erzeugung (reine
|
||||||
|
* Datei-/Domain-Logik) und PDF-Anzeige (Android-Intent/Activity-Start) bleiben unterschiedliche
|
||||||
|
* Verantwortlichkeiten - der Exporter startet niemals eine Activity.
|
||||||
|
*
|
||||||
|
* VulneraDoc implementiert ausdrücklich KEINEN eigenen PDF-Viewer; welche konkrete App den
|
||||||
|
* `ACTION_VIEW`-Intent verarbeitet, entscheidet ausschließlich Android/der Nutzer.
|
||||||
|
*/
|
||||||
|
interface PdfOpener {
|
||||||
|
fun open(file: File): PdfOpenResult
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PdfOpenResult {
|
||||||
|
data object Success : PdfOpenResult
|
||||||
|
|
||||||
|
sealed interface Failure : PdfOpenResult {
|
||||||
|
/** Die referenzierte Datei existiert nicht mehr (z. B. zwischenzeitlich extern entfernt). */
|
||||||
|
data object FileNotFound : Failure
|
||||||
|
|
||||||
|
/** FileProvider konnte für die Datei keine content://-Uri erzeugen. */
|
||||||
|
data class UriResolutionFailed(val message: String) : Failure
|
||||||
|
|
||||||
|
/** Kein installierter Reader kann application/pdf verarbeiten. */
|
||||||
|
data object NoViewerAvailable : Failure
|
||||||
|
|
||||||
|
data class Unexpected(val message: String) : Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fachliche, für die Anzeige (z. B. Snackbar) geeignete Übersetzung eines [PdfOpenResult.Failure]. */
|
||||||
|
fun PdfOpenResult.Failure.toUserMessage(): String = when (this) {
|
||||||
|
PdfOpenResult.Failure.FileNotFound ->
|
||||||
|
"Die PDF-Datei wurde nicht gefunden. Bitte die Dokumentation erneut exportieren."
|
||||||
|
is PdfOpenResult.Failure.UriResolutionFailed ->
|
||||||
|
"Die PDF-Datei konnte nicht zur Anzeige freigegeben werden."
|
||||||
|
PdfOpenResult.Failure.NoViewerAvailable ->
|
||||||
|
"Auf diesem Gerät ist keine App zum Anzeigen von PDF-Dateien installiert."
|
||||||
|
is PdfOpenResult.Failure.Unexpected ->
|
||||||
|
"Die PDF-Datei konnte nicht geöffnet werden."
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidPdfOpener(private val context: Context) : PdfOpener {
|
||||||
|
|
||||||
|
override fun open(file: File): PdfOpenResult {
|
||||||
|
if (!file.exists()) {
|
||||||
|
return PdfOpenResult.Failure.FileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
val authority = "${context.packageName}.fileprovider"
|
||||||
|
val uri = try {
|
||||||
|
FileProvider.getUriForFile(context, authority, file)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// Wird von FileProvider geworfen, wenn die Datei außerhalb der in file_paths.xml
|
||||||
|
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
|
||||||
|
return PdfOpenResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/pdf")
|
||||||
|
// Ausschließlich Lese-, KEINE Schreibberechtigung für den externen Reader.
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
PdfOpenResult.Success
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// Bewusst KEIN vorheriges intent.resolveActivity(...) - das würde auf Android 11+
|
||||||
|
// durch Package-Visibility-Einschränkungen ohne zusätzliche <queries>-Deklaration
|
||||||
|
// im Manifest fälschlich "kein Reader" melden können, auch wenn tatsächlich einer
|
||||||
|
// installiert ist. Der eigentliche startActivity()-Aufruf unterliegt dieser
|
||||||
|
// Einschränkung nicht - ActivityNotFoundException ist hier die zuverlässige Quelle.
|
||||||
|
PdfOpenResult.Failure.NoViewerAvailable
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
PdfOpenResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
PdfOpenResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Öffnen der PDF-Datei.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package de.medapp.wunddoku.pdf
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.pdf.PdfDocument
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.StaticLayout
|
||||||
|
import android.text.TextPaint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet Seiten, Zeichenfläche und die aktuelle vertikale Schreibposition ("Cursor") beim
|
||||||
|
* Erzeugen eines [PdfDocument]. Kapselt die komplette Seitenumbruch-Logik an einer Stelle, damit
|
||||||
|
* [AndroidPdfDocumentExporter] selbst nur noch "was" gezeichnet werden soll beschreiben muss.
|
||||||
|
*
|
||||||
|
* SEITENUMBRUCH-STRATEGIE: [drawText] zerlegt einen Text NICHT durch manuelles Verschieben/
|
||||||
|
* Clippen einzelner Zeilen eines einmal gebauten [StaticLayout]. Stattdessen wird pro
|
||||||
|
* Seiten-Abschnitt ein EIGENES, frisch aufgebautes [StaticLayout] für genau den Textausschnitt
|
||||||
|
* erzeugt, der auf die aktuelle Seite passt (Ausschnittgrenzen liegen dabei immer exakt auf
|
||||||
|
* bereits von Android selbst bestimmten Zeilengrenzen). Jeder Seiten-Abschnitt berechnet damit
|
||||||
|
* seine Baselines/Zeilenhöhen selbst und korrekt aus der StaticLayout-API - es gibt keine
|
||||||
|
* manuelle Koordinatenverschiebung einzelner Zeilen, die zu Baseline-/Zeilenhöhenfehlern führen
|
||||||
|
* könnte. Das erlaubt einen Seitenumbruch exakt zwischen zwei Zeilen und behandelt "Block
|
||||||
|
* größer als eine Seite" automatisch mit, ohne Sonderfall-Code.
|
||||||
|
*/
|
||||||
|
internal class PdfPageCursor(
|
||||||
|
private val pdfDocument: PdfDocument,
|
||||||
|
private val pageWidth: Int,
|
||||||
|
private val pageHeight: Int,
|
||||||
|
private val marginLeft: Float,
|
||||||
|
private val marginRight: Float,
|
||||||
|
private val marginTop: Float,
|
||||||
|
private val marginBottom: Float,
|
||||||
|
) {
|
||||||
|
private var pageNumber = 0
|
||||||
|
private var currentPage: PdfDocument.Page? = null
|
||||||
|
private var canvas: Canvas? = null
|
||||||
|
private var cursorY: Float = marginTop
|
||||||
|
|
||||||
|
private val dividerPaint = Paint().apply {
|
||||||
|
color = Color.LTGRAY
|
||||||
|
strokeWidth = 1f
|
||||||
|
}
|
||||||
|
private val footerPaint = Paint().apply {
|
||||||
|
color = Color.GRAY
|
||||||
|
textSize = 9f
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentWidth: Int
|
||||||
|
get() = (pageWidth - marginLeft - marginRight).toInt()
|
||||||
|
|
||||||
|
/** Verbleibende Höhe auf der aktuellen Seite ab der aktuellen Cursor-Position (PDF-004:
|
||||||
|
* Grundlage für die Einpassung des Fotos nach einer bereits gezeichneten Bildunterschrift). */
|
||||||
|
val remainingHeight: Float
|
||||||
|
get() {
|
||||||
|
startIfNeeded()
|
||||||
|
return (contentBottom() - cursorY).coerceAtLeast(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contentBottom(): Float = pageHeight - marginBottom
|
||||||
|
|
||||||
|
private fun startIfNeeded() {
|
||||||
|
if (currentPage == null) startNewPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startNewPage() {
|
||||||
|
finishCurrentPageIfOpen()
|
||||||
|
pageNumber++
|
||||||
|
val info = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()
|
||||||
|
val page = pdfDocument.startPage(info)
|
||||||
|
currentPage = page
|
||||||
|
canvas = page.canvas
|
||||||
|
cursorY = marginTop
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishCurrentPageIfOpen() {
|
||||||
|
val page = currentPage ?: return
|
||||||
|
// Seitenzahl wird direkt beim Fertigstellen dieser Seite gezeichnet (kein "Seite X von
|
||||||
|
// Y", da die Gesamtseitenzahl an dieser Stelle noch nicht bekannt ist).
|
||||||
|
canvas?.drawText("Seite $pageNumber", pageWidth / 2f, pageHeight - marginBottom / 2f, footerPaint)
|
||||||
|
pdfDocument.finishPage(page)
|
||||||
|
currentPage = null
|
||||||
|
canvas = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzwingt den Beginn einer neuen Seite, unabhängig davon, wie viel Platz auf der aktuellen
|
||||||
|
* Seite noch verfügbar wäre (PDF-004: jedes Foto beginnt zwingend auf einer eigenen neuen
|
||||||
|
* Seite, nie im verbleibenden Platz einer Dokumentations- oder vorherigen Fotoseite). Die
|
||||||
|
* Seitennummerierung läuft dabei fortlaufend weiter, da [startNewPage] intern lediglich
|
||||||
|
* `pageNumber` erhöht, nie zurücksetzt.
|
||||||
|
*/
|
||||||
|
fun forceNewPage() {
|
||||||
|
startNewPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal
|
||||||
|
* über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der
|
||||||
|
* Seite. Setzt voraus, dass die Seite bereits begonnen wurde (i. d. R. direkt nach
|
||||||
|
* [forceNewPage] plus einer optionalen Bildunterschrift über [drawText]) - diese Methode
|
||||||
|
* selbst löst KEINEN Seitenumbruch aus, da ein Foto nicht über zwei Seiten verteilt werden
|
||||||
|
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
|
||||||
|
* hineinskaliert (kleiner, nie abgeschnitten).
|
||||||
|
*/
|
||||||
|
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) {
|
||||||
|
startIfNeeded()
|
||||||
|
val availableWidth = contentWidth.toFloat()
|
||||||
|
val availableHeight = remainingHeight
|
||||||
|
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
|
||||||
|
|
||||||
|
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height)
|
||||||
|
val drawWidth = bitmap.width * scale
|
||||||
|
val drawHeight = bitmap.height * scale
|
||||||
|
val left = marginLeft + (availableWidth - drawWidth) / 2f
|
||||||
|
val top = cursorY + (availableHeight - drawHeight) / 2f
|
||||||
|
|
||||||
|
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
|
||||||
|
cursorY = contentBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal
|
||||||
|
* aufgerufen werden. */
|
||||||
|
fun finish() {
|
||||||
|
finishCurrentPageIfOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun drawDivider(spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
|
||||||
|
startIfNeeded()
|
||||||
|
ensureSpace(spacingBefore + 1f)
|
||||||
|
cursorY += spacingBefore
|
||||||
|
canvas!!.drawLine(marginLeft, cursorY, pageWidth - marginRight, cursorY, dividerPaint)
|
||||||
|
cursorY += 1f + spacingAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserviert [height] vertikalen Platz, beginnt bei Bedarf eine neue Seite. */
|
||||||
|
private fun ensureSpace(height: Float) {
|
||||||
|
startIfNeeded()
|
||||||
|
if (cursorY + height > contentBottom()) {
|
||||||
|
startNewPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliches Gegenstück zu [ensureSpace] für ATOMARE Blöcke, die aus mehreren
|
||||||
|
* Einzelzeichnungen bestehen (PDF-003: ein Signaturblock aus Wundversorgername, Bild, Linie
|
||||||
|
* und Namenszeile darf laut Aufgabenstellung Abschnitt 28 nicht zwischen zwei Seiten
|
||||||
|
* zerrissen werden). Der Aufrufer berechnet VOR dem eigentlichen Zeichnen die
|
||||||
|
* Gesamthöhe des Blocks (s. [measureTextHeight] für die enthaltenen Textanteile) und
|
||||||
|
* reserviert sie hier in einem Rutsch - reicht der verbleibende Platz nicht, beginnt eine
|
||||||
|
* komplett neue Seite, BEVOR auch nur die erste Teilzeichnung des Blocks beginnt. Anders als
|
||||||
|
* [drawText] (das einen zu langen Text selbst auf mehrere Seiten verteilen würde) gibt es
|
||||||
|
* hier keine interne Aufteilung - das ist hier gewünscht, kein Sonderfall.
|
||||||
|
*/
|
||||||
|
fun ensureBlockSpace(height: Float) {
|
||||||
|
ensureSpace(height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet die Gesamthöhe, die [text] beim Zeichnen mit [drawText] belegen würde, OHNE
|
||||||
|
* etwas zu zeichnen oder den Cursor zu verändern. Verwendet zur Berechnung dasselbe
|
||||||
|
* [buildLayout] wie [drawText] selbst (identischer Text/Paint/Breite), damit die hier
|
||||||
|
* gemessene und die später tatsächlich gezeichnete Höhe für ein und denselben Aufruf
|
||||||
|
* garantiert übereinstimmen - Voraussetzung dafür, dass eine vorab über [ensureBlockSpace]
|
||||||
|
* reservierte Blockhöhe beim tatsächlichen Zeichnen exakt ausreicht.
|
||||||
|
*/
|
||||||
|
fun measureTextHeight(text: String, paint: TextPaint, indent: Float = 0f): Float {
|
||||||
|
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
|
||||||
|
return buildLayout(text, paint, width).height.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet [bitmap] proportional in eine FESTE Zielbreite [targetWidth] eingepasst (Höhe
|
||||||
|
* ergibt sich aus dem Seitenverhältnis von [bitmap] - kein Zuschneiden, keine Verzerrung),
|
||||||
|
* linksbündig ab der aktuellen Cursor-Position.
|
||||||
|
*
|
||||||
|
* Bewusst ein GRUNDSÄTZLICH ANDERES Verhalten als [drawBitmapFitted] (PDF-004: füllt gezielt
|
||||||
|
* den GESAMTEN verbleibenden Platz der Seite - richtig für "genau ein Foto pro Seite"): Hier
|
||||||
|
* wird NICHT der restliche Seitenplatz ausgefüllt, sondern exakt die aus [targetWidth]
|
||||||
|
* abgeleitete Zielhöhe gezeichnet und der Cursor auch nur genau um diese Höhe
|
||||||
|
* weiterbewegt - Voraussetzung dafür, dass mehrere Signaturblöcke hintereinander auf
|
||||||
|
* derselben Seite stehen können, statt dass jeder einzelne Block wie ein Foto seine ganze
|
||||||
|
* Seite beansprucht (Aufgabenstellung Abschnitt 28: "Mehrere vollständige Signaturblöcke
|
||||||
|
* dürfen auf derselben Seite stehen, sofern genug Platz vorhanden ist").
|
||||||
|
*
|
||||||
|
* Setzt voraus, dass für den vollständigen Block (inkl. dieses Bildes) bereits vorher
|
||||||
|
* [ensureBlockSpace] aufgerufen wurde - diese Methode selbst löst KEINEN Seitenumbruch aus.
|
||||||
|
*/
|
||||||
|
fun drawBitmapFixedWidth(bitmap: Bitmap, targetWidth: Float, paint: Paint, spacingAfter: Float = 0f): Float {
|
||||||
|
startIfNeeded()
|
||||||
|
if (bitmap.width <= 0 || bitmap.height <= 0) return 0f
|
||||||
|
val drawWidth = targetWidth.coerceAtMost(contentWidth.toFloat())
|
||||||
|
val drawHeight = drawWidth * bitmap.height / bitmap.width
|
||||||
|
val left = marginLeft
|
||||||
|
val top = cursorY
|
||||||
|
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
|
||||||
|
cursorY += drawHeight + spacingAfter
|
||||||
|
return drawHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurze horizontale Linie fester Breite [width] (z. B. eine Unterschriftslinie), linksbündig
|
||||||
|
* ab der aktuellen Cursor-Position - im Gegensatz zu [drawDivider] NICHT über die volle
|
||||||
|
* Inhaltsbreite der Seite gezogen. Setzt wie [drawBitmapFixedWidth] ein vorheriges
|
||||||
|
* [ensureBlockSpace] für den gesamten umgebenden Block voraus.
|
||||||
|
*/
|
||||||
|
fun drawShortLine(width: Float, spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
|
||||||
|
startIfNeeded()
|
||||||
|
cursorY += spacingBefore
|
||||||
|
canvas!!.drawLine(marginLeft, cursorY, marginLeft + width, cursorY, dividerPaint)
|
||||||
|
cursorY += spacingAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert
|
||||||
|
* über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach
|
||||||
|
* rechts (z. B. für eingerückten Freitext unter einer Checkbox-Zeile).
|
||||||
|
*
|
||||||
|
* [leadingMark], falls gesetzt, wird EINMALIG unmittelbar vor der allerersten gezeichneten
|
||||||
|
* Textzeile aufgerufen - Aufrufparameter sind (Canvas, linke X-Position vor dem Einzug,
|
||||||
|
* Geräte-Y-Oberkante, Geräte-Y-Unterkante) der ersten Zeile. Dient z. B. dazu, links neben
|
||||||
|
* dem Text ein Checkbox-Symbol zu zeichnen (s. [AndroidPdfDocumentExporter]). Kästchen und
|
||||||
|
* erste Zeile werden dabei als untrennbare Einheit behandelt: Reicht der Platz auf der
|
||||||
|
* aktuellen Seite nicht einmal für die erste Zeile, wird vorher auf eine neue Seite
|
||||||
|
* gewechselt - das Kästchen kann also niemals von seiner Zeile getrennt auf eine andere
|
||||||
|
* Seite geraten.
|
||||||
|
*/
|
||||||
|
fun drawText(
|
||||||
|
text: String,
|
||||||
|
paint: TextPaint,
|
||||||
|
indent: Float = 0f,
|
||||||
|
spacingAfter: Float = 0f,
|
||||||
|
leadingMark: ((canvas: Canvas, left: Float, top: Float, bottom: Float) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
|
||||||
|
val fullLayout = buildLayout(text, paint, width)
|
||||||
|
var lineIndex = 0
|
||||||
|
val lineCount = fullLayout.lineCount
|
||||||
|
var isFirstLine = true
|
||||||
|
|
||||||
|
while (lineIndex < lineCount) {
|
||||||
|
startIfNeeded()
|
||||||
|
val remaining = contentBottom() - cursorY
|
||||||
|
|
||||||
|
// Ermittelt, wie viele aufeinanderfolgende Zeilen ab lineIndex noch auf die
|
||||||
|
// aktuelle Seite passen (Ausschnittgrenzen liegen exakt auf Zeilengrenzen).
|
||||||
|
var endLineExclusive = lineIndex
|
||||||
|
var usedHeight = 0f
|
||||||
|
while (endLineExclusive < lineCount) {
|
||||||
|
val lineHeight = (fullLayout.getLineBottom(endLineExclusive) - fullLayout.getLineTop(endLineExclusive)).toFloat()
|
||||||
|
if (usedHeight + lineHeight > remaining) break
|
||||||
|
usedHeight += lineHeight
|
||||||
|
endLineExclusive++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endLineExclusive == lineIndex) {
|
||||||
|
// Nicht einmal eine einzige Zeile passt mehr auf die aktuelle Seite -> neue
|
||||||
|
// Seite beginnen und denselben Ausschnitt erneut versuchen.
|
||||||
|
startNewPage()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val charStart = fullLayout.getLineStart(lineIndex)
|
||||||
|
val charEnd = fullLayout.getLineEnd(endLineExclusive - 1)
|
||||||
|
// Frisches StaticLayout NUR für diesen Seiten-Abschnitt - Android berechnet Zeilen/
|
||||||
|
// Baselines dafür vollständig neu, beginnend bei lokal y = 0 für die erste Zeile
|
||||||
|
// dieses Abschnitts. Keine manuelle Koordinatenübernahme aus fullLayout nötig.
|
||||||
|
val chunkLayout = buildLayout(text.substring(charStart, charEnd), paint, width)
|
||||||
|
|
||||||
|
val c = canvas!!
|
||||||
|
c.save()
|
||||||
|
c.translate(marginLeft + indent, cursorY)
|
||||||
|
chunkLayout.draw(c)
|
||||||
|
c.restore()
|
||||||
|
|
||||||
|
if (isFirstLine && leadingMark != null) {
|
||||||
|
val firstLineHeight = (chunkLayout.getLineBottom(0) - chunkLayout.getLineTop(0)).toFloat()
|
||||||
|
leadingMark(c, marginLeft, cursorY, cursorY + firstLineHeight)
|
||||||
|
isFirstLine = false
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorY += usedHeight
|
||||||
|
lineIndex = endLineExclusive
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorY += spacingAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLayout(text: String, paint: TextPaint, width: Int): StaticLayout {
|
||||||
|
val safeWidth = width.coerceAtLeast(1)
|
||||||
|
return StaticLayout.Builder.obtain(text, 0, text.length, paint, safeWidth)
|
||||||
|
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||||
|
.setLineSpacing(0f, 1.05f)
|
||||||
|
.setIncludePad(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.medapp.wunddoku.signature
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zweite, von der UI unabhängige Absicherung gegen das Speichern einer leeren Signatur
|
||||||
|
* (Aufgabenstellung Abschnitt 12: "Daten-/Storage-Ebene: keine leere PNG-Datei als gültige
|
||||||
|
* Signatur persistieren"). Die erste Ebene ist der deaktivierte "Speichern"-Button in der UI,
|
||||||
|
* solange keine Striche gezeichnet wurden - diese Prüfung greift zusätzlich unabhängig davon in
|
||||||
|
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature], damit ein Aufruf,
|
||||||
|
* der die UI-Absicherung umgeht (Programmierfehler, zukünftiger zweiter Aufrufer o. Ä.), nicht
|
||||||
|
* trotzdem eine leere Signatur persistiert.
|
||||||
|
*/
|
||||||
|
object SignatureBitmapValidation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert `true`, wenn [bitmap] ausschließlich reinweiße Pixel enthält, also keine
|
||||||
|
* tatsächlich gezeichnete Signatur darstellt. Die Signaturzeichenfläche rendert auf einen
|
||||||
|
* weißen Hintergrund; jeder gezeichnete Strich (auch nur teilweise, dank Anti-Aliasing an
|
||||||
|
* den Rändern) erzeugt mindestens einen Pixel, der von reinem Weiß abweicht.
|
||||||
|
*/
|
||||||
|
fun isBlank(bitmap: Bitmap): Boolean {
|
||||||
|
if (bitmap.width <= 0 || bitmap.height <= 0) return true
|
||||||
|
val pixels = IntArray(bitmap.width * bitmap.height)
|
||||||
|
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||||
|
val opaqueWhite = 0xFFFFFFFF.toInt()
|
||||||
|
return pixels.none { pixel -> pixel != opaqueWhite }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package de.medapp.wunddoku.signature
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet die physischen Signatur-PNGs im internen App-Speicher, organisiert pro
|
||||||
|
* Dokumentation - exakt dieselbe Struktur-Idee wie
|
||||||
|
* [de.medapp.wunddoku.media.PhotoStorage]:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* filesDir/
|
||||||
|
* └── signatures/
|
||||||
|
* └── <documentationId>/
|
||||||
|
* └── <signatureId>.png
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Ersetzt den ursprünglichen Platzhalter `SignatureFileStorage`, der noch flach unter
|
||||||
|
* `filesDir/signatures/<uuid>.png` speicherte (ohne Bezug zur jeweiligen Dokumentation). Ein
|
||||||
|
* eigenes Unterverzeichnis pro Dokumentation hat denselben Vorteil wie bei Fotos: [deleteAll]
|
||||||
|
* kann beim Löschen einer kompletten Dokumentation das gesamte Verzeichnis in einem Schritt
|
||||||
|
* entfernen, statt Einzeldateien anhand von DB-Zeilen durchzugehen (Aufgabenstellung
|
||||||
|
* Abschnitt 13/18).
|
||||||
|
*
|
||||||
|
* Persistiert wird ausschließlich das fertige, bereits auf das logische 1000x400-Format
|
||||||
|
* normalisierte Signaturbild als PNG (verlustfrei) - Stroke-/Pfad-Daten sind ausschließlich
|
||||||
|
* Editor-State und werden hier nie entgegengenommen (Aufgabenstellung Abschnitt 11). Alle
|
||||||
|
* Operationen laufen über [Dispatchers.IO], nie auf dem Hauptthread.
|
||||||
|
*/
|
||||||
|
interface SignatureStorage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert [bitmap] als neue PNG-Datei im dokumentationsspezifischen Verzeichnis. Liefert
|
||||||
|
* den relativen Dateinamen (ausschließlich der documentationId, s. [resolve]) - kein
|
||||||
|
* absoluter, geräteabhängiger Pfad wird persistiert (Aufgabenstellung Abschnitt 15/20).
|
||||||
|
*/
|
||||||
|
suspend fun save(documentationId: String, bitmap: Bitmap): String
|
||||||
|
|
||||||
|
/** Löscht eine einzelne physische Signaturdatei. Wirft KEINE Exception, wenn die Datei
|
||||||
|
* nicht (mehr) existiert - analog [de.medapp.wunddoku.media.PhotoStorage.delete]. */
|
||||||
|
suspend fun delete(documentationId: String, fileName: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht das gesamte Signaturverzeichnis einer Dokumentation samt aller enthaltenen Dateien
|
||||||
|
* in einem Schritt (analog [de.medapp.wunddoku.media.PhotoStorage.deleteAll]). Verwendet
|
||||||
|
* beim Löschen der kompletten Dokumentation - unabhängig davon, welche DB-Zeilen zu diesem
|
||||||
|
* Zeitpunkt noch bekannt sind, damit keine verwaisten PNG-Dateien zurückbleiben.
|
||||||
|
*/
|
||||||
|
suspend fun deleteAll(documentationId: String)
|
||||||
|
|
||||||
|
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. */
|
||||||
|
fun resolve(documentationId: String, fileName: String): File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodiert die gespeicherte PNG-Datei in voller Auflösung. Kein Downsampling nötig - das
|
||||||
|
* logische Signaturformat ist mit 1000x400px bereits klein (siehe Zeichenfläche), anders
|
||||||
|
* als importierte Fotos. Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden
|
||||||
|
* kann - PDF-003 (Aufgabenstellung Abschnitt 31) zeigt in diesem Fall einen neutralen
|
||||||
|
* Platzhalter statt abzustürzen.
|
||||||
|
*/
|
||||||
|
suspend fun decode(documentationId: String, fileName: String): Bitmap?
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische Implementierung, organisiert pro "Schlüssel" (bei dokumentationsbezogenen
|
||||||
|
* Signaturen: `documentationId`) in einem eigenen Wurzelverzeichnis [rootDirName]. Wird
|
||||||
|
* ZWEIMAL instanziiert (s. [de.medapp.wunddoku.di.AppContainer]):
|
||||||
|
*
|
||||||
|
* - einmal für die historischen, unveränderlichen Signaturen einzelner Dokumentationen
|
||||||
|
* (`rootDirName = "signatures"`, Schlüssel = documentationId),
|
||||||
|
* - einmal für das Wundversorger-Register mit jeweils nur der AKTUELLSTEN Signatur je Person
|
||||||
|
* (`rootDirName = "wound_carer_signatures"`, Schlüssel = woundCarerId) - s.
|
||||||
|
* [de.medapp.wunddoku.data.repository.WoundCarerRepository].
|
||||||
|
*
|
||||||
|
* Beide Verwendungen sind vom Namen her identisch aufgebaut (`filesDir/<rootDirName>/<key>/`),
|
||||||
|
* aber vollständig unabhängige Verzeichnisbäume - eine gelöschte Dokumentation räumt z. B. NIE
|
||||||
|
* das Wundversorger-Register mit auf, und umgekehrt.
|
||||||
|
*/
|
||||||
|
class InternalSignatureStorage(
|
||||||
|
private val context: Context,
|
||||||
|
private val rootDirName: String = "signatures",
|
||||||
|
) : SignatureStorage {
|
||||||
|
|
||||||
|
private val rootDir: File by lazy {
|
||||||
|
File(context.filesDir, rootDirName).apply { mkdirs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun documentationDirPath(documentationId: String): File =
|
||||||
|
File(rootDir, documentationId)
|
||||||
|
|
||||||
|
private fun ensureDocumentationDir(documentationId: String): File =
|
||||||
|
documentationDirPath(documentationId).apply { mkdirs() }
|
||||||
|
|
||||||
|
override fun resolve(documentationId: String, fileName: String): File =
|
||||||
|
File(documentationDirPath(documentationId), fileName)
|
||||||
|
|
||||||
|
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val fileName = "${UUID.randomUUID()}.png"
|
||||||
|
val targetDir = ensureDocumentationDir(documentationId)
|
||||||
|
FileOutputStream(File(targetDir, fileName)).use { out ->
|
||||||
|
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
|
||||||
|
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
|
||||||
|
// verlangt.
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||||
|
}
|
||||||
|
fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
|
||||||
|
resolve(documentationId, fileName).delete()
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
|
||||||
|
documentationDirPath(documentationId).deleteRecursively()
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val file = resolve(documentationId, fileName)
|
||||||
|
if (!file.exists()) return@withContext null
|
||||||
|
runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package de.medapp.wunddoku.ui.components.format
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale, wiederverwendbare Formatierungs-/Transformationslösung für ziffernbasierte
|
||||||
|
* Eingabefelder (Geburtsdatum, Telefonnummern). Bewusst als EINE generische Klasse statt
|
||||||
|
* getrennter Lösungen pro Feldtyp, damit die Formatierungslogik nicht auf mehrere Screens
|
||||||
|
* verteilt wird.
|
||||||
|
*
|
||||||
|
* Funktionsweise: Der zugrunde liegende Wert (`value` im TextField) bleibt immer eine reine
|
||||||
|
* Ziffernfolge ohne Trennzeichen - das ist die Quelle der Wahrheit im ViewModel. Die
|
||||||
|
* [VisualTransformation] fügt Trennzeichen ausschließlich für die DARSTELLUNG ein, ohne den
|
||||||
|
* zugrunde liegenden Text zu verändern. Dadurch bleibt die Persistenzschicht vollständig
|
||||||
|
* unabhängig von der UI-Darstellung: Repository/DAO sehen nur die rohe Ziffernfolge
|
||||||
|
* (Telefonnummern) bzw. eine daraus geparste LocalDate (Geburtsdatum).
|
||||||
|
*
|
||||||
|
* [groupSizes] beschreibt die Länge jeder Zifferngruppe von links nach rechts (z. B. [2,2,4]
|
||||||
|
* für TT.MM.JJJJ). Ziffern, die über die Summe aller Gruppen hinausgehen, werden ohne weitere
|
||||||
|
* Gruppierung angehängt (z. B. [5] für Telefonnummern: nur nach der fünften Ziffer ein
|
||||||
|
* Leerzeichen, danach durchgehend).
|
||||||
|
*/
|
||||||
|
class DigitGroupVisualTransformation(
|
||||||
|
private val groupSizes: List<Int>,
|
||||||
|
private val separator: Char,
|
||||||
|
) : VisualTransformation {
|
||||||
|
|
||||||
|
override fun filter(text: AnnotatedString): TransformedText {
|
||||||
|
val digits = text.text
|
||||||
|
val formatted = StringBuilder()
|
||||||
|
// separatorsBeforeDigit[i] = Anzahl der Trennzeichen, die in der formatierten Darstellung
|
||||||
|
// VOR der i-ten Ziffer stehen - wird für die Cursor-Rückrechnung (OffsetMapping) benötigt.
|
||||||
|
val separatorsBeforeDigit = IntArray(digits.length + 1)
|
||||||
|
|
||||||
|
var groupIndex = 0
|
||||||
|
var posInGroup = 0
|
||||||
|
for (i in digits.indices) {
|
||||||
|
val currentGroupSize = groupSizes.getOrNull(groupIndex)
|
||||||
|
if (currentGroupSize != null && posInGroup == currentGroupSize) {
|
||||||
|
formatted.append(separator)
|
||||||
|
groupIndex++
|
||||||
|
posInGroup = 0
|
||||||
|
}
|
||||||
|
separatorsBeforeDigit[i] = formatted.count { it == separator }
|
||||||
|
formatted.append(digits[i])
|
||||||
|
posInGroup++
|
||||||
|
}
|
||||||
|
separatorsBeforeDigit[digits.length] = formatted.count { it == separator }
|
||||||
|
|
||||||
|
val offsetMapping = object : OffsetMapping {
|
||||||
|
override fun originalToTransformed(offset: Int): Int {
|
||||||
|
val clamped = offset.coerceIn(0, digits.length)
|
||||||
|
return clamped + separatorsBeforeDigit[clamped]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun transformedToOriginal(offset: Int): Int {
|
||||||
|
val clamped = offset.coerceIn(0, formatted.length)
|
||||||
|
var digitCount = 0
|
||||||
|
for (i in 0 until clamped) {
|
||||||
|
if (formatted[i] != separator) digitCount++
|
||||||
|
}
|
||||||
|
return digitCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TransformedText(AnnotatedString(formatted.toString()), offsetMapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtert eine Roheingabe auf reine Ziffern und begrenzt sie auf [maxDigits] Zeichen. */
|
||||||
|
fun filterDigits(input: String, maxDigits: Int): String =
|
||||||
|
input.filter { it.isDigit() }.take(maxDigits)
|
||||||
|
|
||||||
|
/** Gruppierung TT.MM.JJJJ, maximal 8 Ziffern. */
|
||||||
|
val DATE_GROUP_SIZES = listOf(2, 2, 4)
|
||||||
|
const val DATE_MAX_DIGITS = 8
|
||||||
|
|
||||||
|
/** Gruppierung: erste 5 Ziffern, dann ein Leerzeichen, danach ungruppiert. */
|
||||||
|
val PHONE_GROUP_SIZES = listOf(5)
|
||||||
|
const val PHONE_MAX_DIGITS = 15 // großzügige, defensive Obergrenze (E.164-Maximallänge)
|
||||||
|
|
||||||
|
const val POSTAL_CODE_MAX_DIGITS = 5 // deutsche PLZ
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt eine 8-stellige Ziffernfolge im Format TTMMJJJJ in ein [LocalDate] um, oder liefert
|
||||||
|
* `null`, wenn die Länge nicht stimmt oder das Datum ungültig ist (z. B. "31022000" für den
|
||||||
|
* nicht existierenden 31. Februar). `LocalDate.of(...)` wirft in diesem Fall eine
|
||||||
|
* `DateTimeException`, die hier zu `null` statt einer stillschweigenden Fehlerkorrektur führt.
|
||||||
|
*/
|
||||||
|
fun germanDateDigitsToLocalDateOrNull(digits: String): LocalDate? {
|
||||||
|
if (digits.length != DATE_MAX_DIGITS) return null
|
||||||
|
return try {
|
||||||
|
val day = digits.substring(0, 2).toInt()
|
||||||
|
val month = digits.substring(2, 4).toInt()
|
||||||
|
val year = digits.substring(4, 8).toInt()
|
||||||
|
LocalDate.of(year, month, day)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kehrfunktion zu [germanDateDigitsToLocalDateOrNull]: liefert die TTMMJJJJ-Ziffernfolge. */
|
||||||
|
fun LocalDate.toGermanDateDigits(): String =
|
||||||
|
"%02d%02d%04d".format(dayOfMonth, monthValue, year)
|
||||||
+828
-55
@@ -1,44 +1,110 @@
|
|||||||
package de.medapp.wunddoku.ui.documentation.edit
|
package de.medapp.wunddoku.ui.documentation.edit
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
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.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.medapp.wunddoku.R
|
import de.medapp.wunddoku.R
|
||||||
import de.medapp.wunddoku.domain.model.CheckItem
|
import de.medapp.wunddoku.domain.model.CheckItem
|
||||||
|
import de.medapp.wunddoku.domain.model.InputFieldDef
|
||||||
|
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||||
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
|
import de.medapp.wunddoku.domain.model.Signature
|
||||||
|
import de.medapp.wunddoku.pdf.AndroidPdfImageSharer
|
||||||
|
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.PdfShareResult
|
||||||
|
import de.medapp.wunddoku.pdf.toUserMessage
|
||||||
|
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2,
|
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
||||||
* Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und
|
* Vor-/Zurück-Buttons und Positionsanzeige ("7 / 20"), darunter dokumentationsweit (nicht
|
||||||
* Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen
|
* blockbezogen) den Fotobereich (MEDIA-001/002). Die von der Aufgabenstellung bevorzugte
|
||||||
* Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5.
|
* Swipe-Geste zwischen Blöcken sowie Signatur/PDF-Export folgen in späteren Phasen.
|
||||||
|
*
|
||||||
|
* Der Inhalt ist als [LazyColumn] aufgebaut (statt eines einzelnen `Column` mit
|
||||||
|
* `verticalScroll`), damit Foto-Vorschaubilder nur decodiert werden, wenn ihre Zeile tatsächlich
|
||||||
|
* sichtbar ist/wird - wichtig, da die Anzahl der Fotos einer Dokumentation nicht künstlich
|
||||||
|
* begrenzt werden soll (Aufgabenstellung MEDIA-002) und jedes zusätzliche gleichzeitig
|
||||||
|
* gehaltene Vorschaubild RAM kostet.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -48,10 +114,16 @@ fun DocumentationEditScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Vollbildansicht eines Fotos beim Antippen einer Kachel (Änderungswunsch) - rein lokaler
|
||||||
|
// UI-Zustand, bewusst nicht im ViewModel gehalten (analog zu den transient geladenen
|
||||||
|
// Thumbnail-/Signatur-Bitmaps, s. Kommentare an PhotoThumbnail/loadPhotoThumbnail): welches
|
||||||
|
// Foto gerade großformatig angezeigt wird, ist reine Präsentationslogik ohne fachliche
|
||||||
|
// Bedeutung, die einen Prozesstod überdauern müsste.
|
||||||
|
var viewedPhoto by remember { mutableStateOf<Photo?>(null) }
|
||||||
|
|
||||||
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
||||||
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument
|
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
|
||||||
// Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche
|
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
|
||||||
// Absicherung für den häufigen Fall.
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -63,15 +135,82 @@ fun DocumentationEditScreen(
|
|||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android Photo Picker (MEDIA-001): benötigt KEINE READ_MEDIA_IMAGES-/
|
||||||
|
// READ_EXTERNAL_STORAGE-Berechtigung, da das System die Auswahl übernimmt und der App nur
|
||||||
|
// Zugriff auf die konkret gewählten Bilder gewährt. `PickMultipleVisualMedia` erlaubt
|
||||||
|
// Mehrfachauswahl, `ImageOnly` beschränkt die Auswahl auf Bilder. Bricht der Nutzer den
|
||||||
|
// Picker ohne Auswahl ab, liefert der Callback eine leere Liste - das wird im ViewModel
|
||||||
|
// (onPhotosPicked) als No-Op behandelt, nicht als Fehler.
|
||||||
|
val photoPickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.PickMultipleVisualMedia(),
|
||||||
|
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
|
||||||
|
|
||||||
|
// Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler
|
||||||
|
// "ANZEIGEN"-Aktion (PDF-005). PdfOpener wird bewusst hier in der Compose-Schicht instanziiert
|
||||||
|
// (mit dem Activity-Context aus LocalContext, analog zum Photo-Picker-Launcher oben) - das
|
||||||
|
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
|
||||||
|
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
|
||||||
|
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(uiState.pdfExportMessage) {
|
||||||
|
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
||||||
|
val exportedFile = uiState.lastExportedPdfFile
|
||||||
|
// "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte
|
||||||
|
// Datei vorliegt - bei einem fehlgeschlagenen Export ist exportedFile null und die
|
||||||
|
// Snackbar zeigt keine Aktion an.
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = message,
|
||||||
|
actionLabel = if (exportedFile != null) "ANZEIGEN" else null,
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed && exportedFile != null) {
|
||||||
|
when (val openResult = pdfOpener.open(exportedFile)) {
|
||||||
|
PdfOpenResult.Success -> Unit
|
||||||
|
is PdfOpenResult.Failure -> snackbarHostState.showSnackbar(openResult.toUserMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.dismissPdfExportMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ä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) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
if (uiState.blockCount > 0) {
|
if (uiState.blockCount > 0) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.documentation_edit_position,
|
R.string.documentation_edit_progress,
|
||||||
uiState.currentBlockIndex + 1,
|
uiState.editedBlockCount,
|
||||||
uiState.blockCount,
|
uiState.blockCount,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -80,9 +219,27 @@ fun DocumentationEditScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die
|
// PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
|
||||||
// Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave
|
// erzeugt die Datei im internen Speicher (PDF-001/002).
|
||||||
// gesichert, kein expliziter Speichern-Button nötig).
|
if (uiState.pdfExportInProgress) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = viewModel::exportPdf) {
|
||||||
|
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
|
||||||
|
// Autosave gesichert, kein expliziter Speichern-Button nötig).
|
||||||
Button(onClick = onFinished) { Text("Fertig") }
|
Button(onClick = onFinished) { Text("Fertig") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -101,68 +258,542 @@ fun DocumentationEditScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val documentation = uiState.documentation
|
val documentation = uiState.documentation
|
||||||
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex)
|
if (documentation == null || documentation.blocks.isEmpty()) {
|
||||||
if (documentation == null || currentBlock == null) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||||
Text("Diese Dokumentation enthält keine Blöcke.")
|
Text("Diese Dokumentation enthält keine Blöcke.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DocumentationBlockContent(
|
DocumentationEditContent(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
block = currentBlock,
|
blocks = documentation.blocks,
|
||||||
|
expandedBlockId = uiState.expandedBlockId,
|
||||||
|
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
|
||||||
|
photos = documentation.photos,
|
||||||
|
signatures = documentation.signatures,
|
||||||
onCheckedChanged = viewModel::onCheckedChanged,
|
onCheckedChanged = viewModel::onCheckedChanged,
|
||||||
onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) },
|
onFreeTextChanged = viewModel::onFreeTextChanged,
|
||||||
canGoBack = uiState.currentBlockIndex > 0,
|
photoImportInProgress = uiState.photoImportInProgress,
|
||||||
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
|
photoImportError = uiState.photoImportError,
|
||||||
onPrevious = viewModel::onPreviousBlock,
|
onDismissPhotoImportError = viewModel::dismissPhotoImportError,
|
||||||
onNext = viewModel::onNextBlock,
|
onAddPhotosClick = {
|
||||||
|
photoPickerLauncher.launch(
|
||||||
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDeletePhotoRequested = viewModel::requestDeletePhoto,
|
||||||
|
loadThumbnail = viewModel::loadPhotoThumbnail,
|
||||||
|
onPhotoClick = { viewedPhoto = it },
|
||||||
|
onAddSignatureClick = viewModel::requestAddSignature,
|
||||||
|
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
|
||||||
|
loadSignatureBitmap = viewModel::loadSignatureBitmap,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pendingDeletePhotoId = uiState.pendingDeletePhotoId
|
||||||
|
if (pendingDeletePhotoId != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissDeletePhoto,
|
||||||
|
title = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.documentation_edit_delete_photo_confirm_text)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = viewModel::confirmDeletePhoto) {
|
||||||
|
Text(stringResource(R.string.action_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = viewModel::dismissDeletePhoto) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene
|
||||||
|
// Navigationsroute, s. Aufgabenstellung Abschnitt 7).
|
||||||
|
if (uiState.showAddSignatureDialog) {
|
||||||
|
AddSignatureDialog(
|
||||||
|
onDismiss = viewModel::dismissAddSignature,
|
||||||
|
onSave = viewModel::saveSignature,
|
||||||
|
loadDefaultWoundCarer = viewModel::getDefaultWoundCarer,
|
||||||
|
searchWoundCarers = viewModel::searchWoundCarers,
|
||||||
|
loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung
|
||||||
|
// Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher
|
||||||
|
// deaktiviert, solange Name oder Zeichnung leer sind.
|
||||||
|
val signatureError = uiState.signatureError
|
||||||
|
if (signatureError != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissSignatureError,
|
||||||
|
title = { Text(stringResource(R.string.documentation_edit_signatures_title)) },
|
||||||
|
text = { Text(signatureError) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = viewModel::dismissSignatureError) {
|
||||||
|
Text(stringResource(R.string.action_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId
|
||||||
|
if (pendingDeleteSignatureId != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissDeleteSignature,
|
||||||
|
title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = viewModel::confirmDeleteSignature) {
|
||||||
|
Text(stringResource(R.string.action_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = viewModel::dismissDeleteSignature) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Änderungswunsch: Foto in Originalgröße anzeigen.
|
||||||
|
val photoToView = viewedPhoto
|
||||||
|
if (photoToView != null) {
|
||||||
|
PhotoViewerDialog(
|
||||||
|
photo = photoToView,
|
||||||
|
loadFullSize = viewModel::loadPhotoFullSize,
|
||||||
|
onDismiss = { viewedPhoto = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons.
|
||||||
|
if (uiState.showShareFormatDialog) {
|
||||||
|
ShareFormatDialog(
|
||||||
|
onDismiss = viewModel::dismissShareFormatDialog,
|
||||||
|
onChooseImages = viewModel::shareAsImages,
|
||||||
|
onChoosePdf = viewModel::shareAsPdf,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als
|
||||||
|
* PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird
|
||||||
|
* überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) -
|
||||||
|
* kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ShareFormatDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChooseImages: () -> Unit,
|
||||||
|
onChoosePdf: () -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
|
||||||
|
Text("Teilen als", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Als Bilder (JPG) oder als PDF teilen?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
OutlinedButton(onClick = onChooseImages) { Text("JPG") }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(onClick = onChoosePdf) { Text("PDF") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
|
||||||
|
* (Änderungswunsch) - zunächst stehen nur die Überschriften untereinander, Antippen einer
|
||||||
|
* Überschrift klappt genau diesen Block auf und schließt dabei automatisch einen zuvor
|
||||||
|
* aufgeklappten (s. [DocumentationEditViewModel.toggleBlockExpanded] - IMMER höchstens ein
|
||||||
|
* Block gleichzeitig offen) - gefolgt vom dokumentationsweiten Signatur- und Fotobereich. Als
|
||||||
|
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
|
||||||
|
* sind/werden.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DocumentationBlockContent(
|
private fun DocumentationEditContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
block: DocumentationBlock,
|
blocks: List<DocumentationBlock>,
|
||||||
|
expandedBlockId: String?,
|
||||||
|
onToggleBlockExpanded: (blockStateId: String) -> Unit,
|
||||||
|
photos: List<Photo>,
|
||||||
|
signatures: List<Signature>,
|
||||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||||
onFreeTextChanged: (String) -> Unit,
|
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||||
canGoBack: Boolean,
|
photoImportInProgress: Boolean,
|
||||||
canGoNext: Boolean,
|
photoImportError: String?,
|
||||||
onPrevious: () -> Unit,
|
onDismissPhotoImportError: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onAddPhotosClick: () -> Unit,
|
||||||
|
onDeletePhotoRequested: (photoId: String) -> Unit,
|
||||||
|
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
|
onPhotoClick: (Photo) -> Unit,
|
||||||
|
onAddSignatureClick: () -> Unit,
|
||||||
|
onDeleteSignatureRequested: (signatureId: String) -> Unit,
|
||||||
|
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
) {
|
) {
|
||||||
Column(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.verticalScroll(rememberScrollState())
|
contentPadding = PaddingValues(16.dp),
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(block.title, style = MaterialTheme.typography.headlineSmall)
|
items(blocks, key = { it.blockStateId }) { block ->
|
||||||
|
BlockAccordionItem(
|
||||||
|
block = block,
|
||||||
|
expanded = block.blockStateId == expandedBlockId,
|
||||||
|
onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
|
||||||
|
onCheckedChanged = onCheckedChanged,
|
||||||
|
onFreeTextChanged = onFreeTextChanged,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
block.items.forEach { item -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) }
|
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
|
||||||
|
// darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im
|
||||||
OutlinedTextField(
|
// späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003).
|
||||||
value = block.freeText,
|
item(key = "signatures_header") {
|
||||||
onValueChange = onFreeTextChanged,
|
Column {
|
||||||
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Row(
|
||||||
minLines = 3,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
Row(
|
) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
Text(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
stringResource(R.string.documentation_edit_signatures_title),
|
||||||
) {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
|
)
|
||||||
Text(stringResource(R.string.documentation_edit_previous))
|
OutlinedButton(onClick = onAddSignatureClick) {
|
||||||
}
|
Icon(Icons.Filled.Add, contentDescription = null)
|
||||||
Button(onClick = onNext, enabled = canGoNext) {
|
Spacer(Modifier.width(4.dp))
|
||||||
Text(stringResource(R.string.documentation_edit_next))
|
Text(stringResource(R.string.documentation_edit_add_signature))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (signatures.isEmpty()) {
|
||||||
|
item(key = "signatures_empty") {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.documentation_edit_no_signatures),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(signatures, key = { it.id }) { signature ->
|
||||||
|
SignatureRow(
|
||||||
|
signature = signature,
|
||||||
|
loadBitmap = loadSignatureBitmap,
|
||||||
|
onDeleteRequested = { onDeleteSignatureRequested(signature.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "photos_header") {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.documentation_edit_photos_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (photoImportInProgress) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.documentation_edit_add_photos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photoImportError != null) {
|
||||||
|
item(key = "photo_import_error") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
photoImportError,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDismissPhotoImportError) { Text("OK") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.isEmpty()) {
|
||||||
|
item(key = "photos_empty") {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.documentation_edit_no_photos),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
|
||||||
|
PhotoRow(
|
||||||
|
photos = row,
|
||||||
|
loadThumbnail = loadThumbnail,
|
||||||
|
onDeleteRequested = onDeletePhotoRequested,
|
||||||
|
onPhotoClick = onPhotoClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PHOTO_GRID_COLUMNS = 3
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhotoRow(
|
||||||
|
photos: List<Photo>,
|
||||||
|
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
|
onDeleteRequested: (photoId: String) -> Unit,
|
||||||
|
onPhotoClick: (Photo) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
photos.forEach { photo ->
|
||||||
|
PhotoThumbnail(
|
||||||
|
photo = photo,
|
||||||
|
loadThumbnail = loadThumbnail,
|
||||||
|
onDeleteRequested = { onDeleteRequested(photo.id) },
|
||||||
|
onClick = { onPhotoClick(photo) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine einzelne Foto-Kachel im Fotogitter. Lädt ihre Vorschauversion selbst, transient über
|
||||||
|
* [LaunchedEffect]/`remember` (an `photo.id` gebunden) - das entstehende [Bitmap] wird
|
||||||
|
* ausschließlich lokal in dieser Composable gehalten, nicht im zentralen ViewModel-Zustand
|
||||||
|
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
|
||||||
|
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
|
||||||
|
* sichtbar ist), wird der Zustand automatisch freigegeben.
|
||||||
|
*
|
||||||
|
* Ein Antippen der Kachel (außerhalb des Löschen-Buttons) öffnet die Vollbildansicht
|
||||||
|
* (Änderungswunsch) - [onClick] statt eines eigenen Ladevorgangs hier, da die Kachel selbst nur
|
||||||
|
* das kleine, bereits geladene Thumbnail kennt.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PhotoThumbnail(
|
||||||
|
photo: Photo,
|
||||||
|
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
|
onDeleteRequested: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var loadFailed by remember(photo.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(photo.id) {
|
||||||
|
val result = runCatching { loadThumbnail(photo.documentationId, photo.fileName) }.getOrNull()
|
||||||
|
bitmap = result
|
||||||
|
loadFailed = result == null
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(96.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(enabled = bitmap != null, onClick = onClick),
|
||||||
|
) {
|
||||||
|
val currentBitmap = bitmap
|
||||||
|
when {
|
||||||
|
currentBitmap != null -> Image(
|
||||||
|
bitmap = currentBitmap.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
|
||||||
|
loadFailed -> Icon(
|
||||||
|
Icons.Filled.BrokenImage,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp).align(Alignment.Center),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDeleteRequested,
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).size(28.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.documentation_action_delete),
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und
|
||||||
|
* Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre
|
||||||
|
* Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) -
|
||||||
|
* analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel.
|
||||||
|
* Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es
|
||||||
|
* hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SignatureRow(
|
||||||
|
signature: Signature,
|
||||||
|
loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||||
|
onDeleteRequested: () -> Unit,
|
||||||
|
) {
|
||||||
|
var bitmap by remember(signature.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var loadFailed by remember(signature.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(signature.id) {
|
||||||
|
val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull()
|
||||||
|
bitmap = result
|
||||||
|
loadFailed = result == null
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(96.dp)
|
||||||
|
.size(96.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White),
|
||||||
|
) {
|
||||||
|
val currentBitmap = bitmap
|
||||||
|
when {
|
||||||
|
currentBitmap != null -> Image(
|
||||||
|
bitmap = currentBitmap.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
|
||||||
|
loadFailed -> Icon(
|
||||||
|
Icons.Filled.BrokenImage,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp).align(Alignment.Center),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = signature.signerName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(onClick = onDeleteRequested) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.documentation_action_delete),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Block im Akkordeon (Änderungswunsch): eine antippbare Kopfzeile mit Titel,
|
||||||
|
* einem kleinen "bearbeitet"-Indikator (grüner Haken, s. [DocumentationBlock.isEdited]) und
|
||||||
|
* einem Pfeil-Icon, das den Auf-/Zugeklappt-Zustand anzeigt. Der eigentliche Inhalt (Checkboxen
|
||||||
|
* inkl. Freitext-Option) wird ausschließlich gerendert, wenn [expanded] `true` ist - das
|
||||||
|
* entspricht fachlich einem Dropdown/Expansion-Panel, technisch einfach bedingtes Komponieren
|
||||||
|
* ohne eigene Lazy-Liste (ein Block hat nur wenige Optionen).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BlockAccordionItem(
|
||||||
|
block: DocumentationBlock,
|
||||||
|
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.inputSpec.isNotEmpty() -> StructuredInputRow(
|
||||||
|
item = item,
|
||||||
|
onCheckedChanged = onCheckedChanged,
|
||||||
|
onFreeTextChanged = onFreeTextChanged,
|
||||||
|
)
|
||||||
|
item.isFreeText -> FreeTextCheckItemRow(
|
||||||
|
item = item,
|
||||||
|
onCheckedChanged = onCheckedChanged,
|
||||||
|
onFreeTextChanged = onFreeTextChanged,
|
||||||
|
)
|
||||||
|
else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,3 +810,145 @@ private fun CheckItemRow(
|
|||||||
Text(item.label)
|
Text(item.label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Freitext-Option eines Blocks. Das Textfeld ist deaktiviert, solange die zugehörige
|
||||||
|
* Checkbox nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option
|
||||||
|
* auch als "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FreeTextCheckItemRow(
|
||||||
|
item: CheckItem,
|
||||||
|
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||||
|
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(
|
||||||
|
checked = item.checked,
|
||||||
|
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
|
||||||
|
)
|
||||||
|
Text(item.label)
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = item.freeTextValue,
|
||||||
|
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) },
|
||||||
|
enabled = item.checked,
|
||||||
|
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 40.dp),
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Eintrag mit strukturierten Eingabefeldern statt eines einfachen Häkchens (Änderungswunsch,
|
||||||
|
* z. B. "Von [ ] bis [ ] Uhr" oder "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
|
||||||
|
* Anders als bei [FreeTextCheckItemRow] gibt es hier KEINE eigene Checkbox zum Freischalten: die
|
||||||
|
* Felder sind immer direkt ausfüllbar, `checked` wird automatisch gesetzt, sobald mindestens ein
|
||||||
|
* Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall sonst (BlockEditedPolicy, PDF-
|
||||||
|
* Filter), nur ohne zusätzlichen Tap zum "Freischalten" der Felder.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StructuredInputRow(
|
||||||
|
item: CheckItem,
|
||||||
|
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||||
|
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||||
|
) {
|
||||||
|
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
|
||||||
|
val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) }
|
||||||
|
|
||||||
|
fun updateValue(fieldIndex: Int, newValue: String) {
|
||||||
|
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
|
||||||
|
val encoded = spec.encodeValues(newValues)
|
||||||
|
onFreeTextChanged(item.checkItemStateId, encoded)
|
||||||
|
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(item.label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
spec.fields.forEachIndexed { index, field ->
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
when (field) {
|
||||||
|
is InputFieldDef.Number -> OutlinedTextField(
|
||||||
|
value = values.getOrElse(index) { "" },
|
||||||
|
onValueChange = { raw ->
|
||||||
|
var seenComma = false
|
||||||
|
val filtered = buildString {
|
||||||
|
for (c in raw) {
|
||||||
|
when {
|
||||||
|
c.isDigit() -> append(c)
|
||||||
|
field.allowDecimal && (c == ',' || c == '.') && !seenComma -> {
|
||||||
|
append(',')
|
||||||
|
seenComma = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.take(field.maxLength)
|
||||||
|
updateValue(index, filtered)
|
||||||
|
},
|
||||||
|
label = { Text(field.label) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
is InputFieldDef.Choice -> ChoiceField(
|
||||||
|
field = field,
|
||||||
|
selected = values.getOrElse(index) { "" },
|
||||||
|
onSelected = { updateValue(index, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einfache Auswahlliste (Tippen öffnet ein Dropdown) für [InputFieldDef.Choice]-Felder - bewusst
|
||||||
|
* ein schlichter Button + [DropdownMenu] statt `ExposedDropdownMenuBox`, um ohne zusätzliche
|
||||||
|
* experimentelle Material3-APIs auszukommen. */
|
||||||
|
@Composable
|
||||||
|
private fun ChoiceField(
|
||||||
|
field: InputFieldDef.Choice,
|
||||||
|
selected: String,
|
||||||
|
onSelected: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(selected.ifBlank { field.label }, maxLines = 1)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
field.options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option) },
|
||||||
|
onClick = {
|
||||||
|
onSelected(option)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+427
-21
@@ -1,5 +1,7 @@
|
|||||||
package de.medapp.wunddoku.ui.documentation.edit
|
package de.medapp.wunddoku.ui.documentation.edit
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.createSavedStateHandle
|
import androidx.lifecycle.createSavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -7,8 +9,14 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||||
|
import de.medapp.wunddoku.data.repository.WoundCarerRepository
|
||||||
import de.medapp.wunddoku.di.wunddokuApplication
|
import de.medapp.wunddoku.di.wunddokuApplication
|
||||||
import de.medapp.wunddoku.domain.model.Documentation
|
import de.medapp.wunddoku.domain.model.Documentation
|
||||||
|
import de.medapp.wunddoku.domain.model.WoundCarer
|
||||||
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
|
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||||
|
import de.medapp.wunddoku.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
|
||||||
@@ -18,14 +26,76 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class DocumentationEditUiState(
|
data class DocumentationEditUiState(
|
||||||
val documentation: Documentation? = null,
|
val documentation: Documentation? = null,
|
||||||
val currentBlockIndex: Int = 0,
|
/** ID des BlockState, der gerade im Akkordeon aufgeklappt ist - `null`, wenn keiner
|
||||||
|
* aufgeklappt ist (Änderungswunsch: zunächst nur alle Überschriften untereinander, Antippen
|
||||||
|
* einer Überschrift klappt sie auf). Es ist IMMER höchstens ein Block gleichzeitig
|
||||||
|
* aufgeklappt, s. [DocumentationEditViewModel.toggleBlockExpanded]. */
|
||||||
|
val expandedBlockId: String? = null,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
|
||||||
|
val photoImportInProgress: Boolean = false,
|
||||||
|
/** Zusammenfassende Fehlermeldung, falls beim letzten Importvorgang einzelne Bilder
|
||||||
|
* fehlgeschlagen sind - erfolgreich importierbare Bilder werden trotzdem übernommen. */
|
||||||
|
val photoImportError: String? = null,
|
||||||
|
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
|
||||||
|
val pendingDeletePhotoId: String? = null,
|
||||||
|
/** true, solange der "Signatur hinzufügen"-Dialog angezeigt wird (MEDIA-003). */
|
||||||
|
val showAddSignatureDialog: Boolean = false,
|
||||||
|
/** Fehlermeldung aus einer fehlgeschlagenen Signatur-Validierung (zweite, von der UI
|
||||||
|
* unabhängige Absicherungsebene in [DocumentationRepository.saveSignature] - im Normalfall
|
||||||
|
* verhindert bereits der deaktivierte "Speichern"-Button im Dialog diesen Fall). */
|
||||||
|
val signatureError: String? = null,
|
||||||
|
/** != null, solange die Löschbestätigung für eine Signatur angezeigt werden soll. */
|
||||||
|
val pendingDeleteSignatureId: String? = null,
|
||||||
|
/** true, während ein PDF-Export läuft (Ladeindikator, Button währenddessen deaktiviert). */
|
||||||
|
val pdfExportInProgress: Boolean = false,
|
||||||
|
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
|
||||||
|
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
|
||||||
|
val pdfExportMessage: String? = null,
|
||||||
|
/**
|
||||||
|
* Die zuletzt erfolgreich erzeugte PDF-Datei dieser Dokumentation (PDF-005: "ANZEIGEN" in
|
||||||
|
* der Snackbar). `null` sowohl vor dem ersten Export als auch nach einem fehlgeschlagenen
|
||||||
|
* Export - "ANZEIGEN" wird nur angeboten, wenn hier tatsächlich eine Datei vorliegt. Wird
|
||||||
|
* bei jedem neuen Exportversuch zunächst zurückgesetzt, damit niemals eine veraltete Datei
|
||||||
|
* referenziert werden kann, während ein neuer Export läuft.
|
||||||
|
*/
|
||||||
|
val lastExportedPdfFile: File? = null,
|
||||||
|
/** 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +114,7 @@ data class DocumentationEditUiState(
|
|||||||
class DocumentationEditViewModel(
|
class DocumentationEditViewModel(
|
||||||
private val documentationId: String,
|
private val documentationId: String,
|
||||||
private val repository: DocumentationRepository,
|
private val repository: DocumentationRepository,
|
||||||
|
private val woundCarerRepository: WoundCarerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DocumentationEditUiState())
|
private val _uiState = MutableStateFlow(DocumentationEditUiState())
|
||||||
@@ -51,7 +122,8 @@ class DocumentationEditViewModel(
|
|||||||
|
|
||||||
private val freeTextChanges = MutableSharedFlow<Pair<String, String>>(extraBufferCapacity = 64)
|
private val freeTextChanges = MutableSharedFlow<Pair<String, String>>(extraBufferCapacity = 64)
|
||||||
|
|
||||||
/** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */
|
/** Letzter noch nicht persistierter Freitext-Wert je CheckItemState-ID (Freitext-Option
|
||||||
|
* eines Blocks), für [flushPendingChanges]. */
|
||||||
private val pendingFreeText = mutableMapOf<String, String>()
|
private val pendingFreeText = mutableMapOf<String, String>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -74,29 +146,37 @@ class DocumentationEditViewModel(
|
|||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
private fun observeFreeTextChanges() {
|
private fun observeFreeTextChanges() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) ->
|
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (checkItemStateId, text) ->
|
||||||
persistFreeText(blockStateId, text)
|
persistFreeText(checkItemStateId, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun persistFreeText(blockStateId: String, text: String) {
|
private suspend fun persistFreeText(checkItemStateId: String, text: String) {
|
||||||
repository.updateFreeText(blockStateId, text)
|
repository.updateFreeText(checkItemStateId, text)
|
||||||
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
|
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
|
||||||
if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId)
|
if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onFreeTextChanged(blockStateId: String, text: String) {
|
/**
|
||||||
updateLocalFreeText(blockStateId, text)
|
* [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText =
|
||||||
pendingFreeText[blockStateId] = text
|
* true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine
|
||||||
freeTextChanges.tryEmit(blockStateId to text)
|
* Checkbox-Option kein eigenes Feld am Block mehr.
|
||||||
|
*/
|
||||||
|
fun onFreeTextChanged(checkItemStateId: String, text: String) {
|
||||||
|
updateLocalFreeText(checkItemStateId, text)
|
||||||
|
pendingFreeText[checkItemStateId] = text
|
||||||
|
freeTextChanges.tryEmit(checkItemStateId to text)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateLocalFreeText(blockStateId: String, text: String) {
|
private fun updateLocalFreeText(checkItemStateId: String, text: String) {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val documentation = state.documentation ?: return@update state
|
val documentation = state.documentation ?: return@update state
|
||||||
val updatedBlocks = documentation.blocks.map { block ->
|
val updatedBlocks = documentation.blocks.map { block ->
|
||||||
if (block.blockStateId == blockStateId) block.copy(freeText = text) else block
|
val updatedItems = block.items.map { item ->
|
||||||
|
if (item.checkItemStateId == checkItemStateId) item.copy(freeTextValue = text) else item
|
||||||
|
}
|
||||||
|
block.copy(items = updatedItems)
|
||||||
}
|
}
|
||||||
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
||||||
}
|
}
|
||||||
@@ -118,20 +198,345 @@ 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 ->
|
* Importiert die vom Android Photo Picker ausgewählten Bilder (MEDIA-001). Ein leerer
|
||||||
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
|
* [uris] (Nutzer hat den Picker ohne Auswahl verlassen) ist kein Fehler und wird
|
||||||
|
* stillschweigend ignoriert.
|
||||||
|
*
|
||||||
|
* Jedes Bild wird EINZELN abgefangen: Schlägt der Import eines Bildes fehl (defektes
|
||||||
|
* Format, nicht mehr lesbare Uri o. Ä.), werden die übrigen, erfolgreich importierbaren
|
||||||
|
* Bilder trotzdem übernommen - ein einzelnes fehlerhaftes Bild darf einen Mehrfachimport
|
||||||
|
* nicht komplett verhindern. Am Ende wird die Dokumentation einmalig neu geladen, damit die
|
||||||
|
* UI konsistent den tatsächlichen Datenbankstand zeigt.
|
||||||
|
*/
|
||||||
|
fun onPhotosPicked(uris: List<Uri>) {
|
||||||
|
if (uris.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
|
||||||
|
var failureCount = 0
|
||||||
|
for (uri in uris) {
|
||||||
|
try {
|
||||||
|
repository.addPhoto(documentationId, uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failureCount++
|
||||||
|
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
|
||||||
|
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
||||||
|
android.util.Log.w(
|
||||||
|
"DocumentationEdit",
|
||||||
|
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val refreshed = repository.getFullDocumentation(documentationId)
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
documentation = refreshed ?: state.documentation,
|
||||||
|
photoImportInProgress = false,
|
||||||
|
photoImportError = if (failureCount > 0) {
|
||||||
|
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dismissPhotoImportError() {
|
||||||
|
_uiState.update { it.copy(photoImportError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet die Löschbestätigung für ein Foto (Aufgabenstellung: "vor dem endgültigen Löschen
|
||||||
|
* soll eine Bestätigung erfolgen"). */
|
||||||
|
fun requestDeletePhoto(photoId: String) {
|
||||||
|
_uiState.update { it.copy(pendingDeletePhotoId = photoId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissDeletePhoto() {
|
||||||
|
_uiState.update { it.copy(pendingDeletePhotoId = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmDeletePhoto() {
|
||||||
|
val photoId = _uiState.value.pendingDeletePhotoId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.removePhoto(photoId)
|
||||||
|
val refreshed = repository.getFullDocumentation(documentationId)
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
documentation = refreshed ?: state.documentation,
|
||||||
|
pendingDeletePhotoId = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */
|
||||||
|
fun requestAddSignature() {
|
||||||
|
_uiState.update { it.copy(showAddSignatureDialog = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAddSignature() {
|
||||||
|
_uiState.update { it.copy(showAddSignatureDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische
|
||||||
|
* 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]).
|
||||||
|
* Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst
|
||||||
|
* NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s.
|
||||||
|
* [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern"
|
||||||
|
* bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte.
|
||||||
|
*/
|
||||||
|
fun saveSignature(signerName: String, bitmap: Bitmap) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.saveSignature(documentationId, signerName, bitmap)
|
||||||
|
val refreshed = repository.getFullDocumentation(documentationId)
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
documentation = refreshed ?: state.documentation,
|
||||||
|
showAddSignatureDialog = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissSignatureError() {
|
||||||
|
_uiState.update { it.copy(signatureError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet die Löschbestätigung für eine Signatur (Aufgabenstellung Abschnitt 17: Signaturen
|
||||||
|
* sind unveränderlich - "korrigieren" bedeutet immer löschen + neu erfassen). */
|
||||||
|
fun requestDeleteSignature(signatureId: String) {
|
||||||
|
_uiState.update { it.copy(pendingDeleteSignatureId = signatureId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissDeleteSignature() {
|
||||||
|
_uiState.update { it.copy(pendingDeleteSignatureId = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmDeleteSignature() {
|
||||||
|
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.removeSignature(signatureId)
|
||||||
|
val refreshed = repository.getFullDocumentation(documentationId)
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
documentation = refreshed ?: state.documentation,
|
||||||
|
pendingDeleteSignatureId = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst den PDF-Export an (PDF-001/002). Rein zum manuellen Testen der Export-Funktionalität
|
||||||
|
* gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit
|
||||||
|
* nicht Teil dieses Schritts, die erzeugte Datei liegt ausschließlich im internen
|
||||||
|
* App-Speicher. Keine rohe Exception erreicht die UI: [PdfExportResult] wird hier in eine
|
||||||
|
* kurze, verständliche Meldung übersetzt.
|
||||||
|
*/
|
||||||
|
fun exportPdf() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(pdfExportInProgress = true, pdfExportMessage = null, lastExportedPdfFile = null)
|
||||||
|
}
|
||||||
|
val result = repository.exportPdf(documentationId)
|
||||||
|
_uiState.update {
|
||||||
|
when (result) {
|
||||||
|
is PdfExportResult.Success -> it.copy(
|
||||||
|
pdfExportInProgress = false,
|
||||||
|
pdfExportMessage = "PDF erfolgreich erstellt",
|
||||||
|
lastExportedPdfFile = result.file,
|
||||||
|
)
|
||||||
|
|
||||||
|
is PdfExportResult.Failure -> it.copy(
|
||||||
|
pdfExportInProgress = false,
|
||||||
|
pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}",
|
||||||
|
lastExportedPdfFile = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissPdfExportMessage() {
|
||||||
|
_uiState.update { it.copy(pdfExportMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ö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) {
|
||||||
|
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
|
||||||
|
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
|
||||||
|
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
|
||||||
|
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
|
||||||
|
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
|
||||||
|
* [DocumentationEditUiState]): Bitmaps sind vergleichsweise schwergewichtig und sollen nur
|
||||||
|
* transient in der jeweiligen Compose-Kachel gehalten werden, nicht dauerhaft im
|
||||||
|
* ViewModel-Zustand. Die eigentliche Datei-/Decodier-Logik bleibt vollständig in
|
||||||
|
* PhotoStorage (über das Repository) - Compose ruft ausschließlich diese suspend-Funktion
|
||||||
|
* auf und führt selbst keinen Datei-/ContentResolver-Zugriff aus.
|
||||||
|
*/
|
||||||
|
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
|
||||||
|
repository.loadPhotoThumbnail(documentationId, fileName)
|
||||||
|
|
||||||
|
/** Für die Vollbildansicht beim Antippen einer Foto-Kachel (Änderungswunsch). */
|
||||||
|
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) =
|
||||||
|
repository.loadPhotoFullSize(documentationId, fileName, maxWidthPx, maxHeightPx)
|
||||||
|
|
||||||
|
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
|
||||||
|
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
|
||||||
|
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
|
||||||
|
repository.loadSignatureBitmap(documentationId, fileName)
|
||||||
|
|
||||||
|
/** Für die Vorbelegung des "Signatur hinzufügen"-Dialogs beim Öffnen: der zuletzt
|
||||||
|
* verwendete Wundversorger app-weit (nicht auf diese Dokumentation beschränkt) - `null`,
|
||||||
|
* wenn noch nie eine Signatur gespeichert wurde. Weiterhin änderbar (Änderungswunsch). */
|
||||||
|
suspend fun getDefaultWoundCarer(): WoundCarer? = woundCarerRepository.getMostRecentlyUsed()
|
||||||
|
|
||||||
|
/** Namensvorschläge für die Autovervollständigung im Signatur-Dialog. */
|
||||||
|
suspend fun searchWoundCarers(query: String): List<WoundCarer> = woundCarerRepository.search(query)
|
||||||
|
|
||||||
|
/** Lädt die hinterlegte Signatur eines bekannten Wundversorgers (Vorbelegung bzw. nach Tippen
|
||||||
|
* auf einen Namensvorschlag) - unabhängig von [loadSignatureBitmap], das historische, pro
|
||||||
|
* Dokumentation gespeicherte Signaturen lädt. */
|
||||||
|
suspend fun loadWoundCarerSignatureBitmap(woundCarerId: String, fileName: String) =
|
||||||
|
woundCarerRepository.loadSignatureBitmap(woundCarerId, fileName)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
|
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
|
||||||
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
|
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
|
||||||
@@ -142,7 +547,7 @@ class DocumentationEditViewModel(
|
|||||||
val toFlush = pendingFreeText.toMap()
|
val toFlush = pendingFreeText.toMap()
|
||||||
pendingFreeText.clear()
|
pendingFreeText.clear()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) }
|
toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +565,7 @@ class DocumentationEditViewModel(
|
|||||||
DocumentationEditViewModel(
|
DocumentationEditViewModel(
|
||||||
documentationId = route.documentationId,
|
documentationId = route.documentationId,
|
||||||
repository = container.documentationRepository,
|
repository = container.documentationRepository,
|
||||||
|
woundCarerRepository = container.woundCarerRepository,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.medapp.wunddoku.ui.documentation.edit
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import de.medapp.wunddoku.R
|
||||||
|
import de.medapp.wunddoku.domain.model.Photo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollbild-Ansicht eines einzelnen Fotos in Originalgröße (Änderungswunsch: "Fotos in der App
|
||||||
|
* durch Antippen in Originalgröße angezeigt werden"). Lädt das Foto über [loadFullSize] separat
|
||||||
|
* von der kleinen Grid-Vorschau ([DocumentationEditViewModel.loadPhotoThumbnail]) - dieselbe
|
||||||
|
* Trennung wie bei den Signatur-Vorschauen: die kleine Kachel und die Vollbildansicht sind
|
||||||
|
* unabhängige Ladevorgänge mit unterschiedlicher Zielauflösung.
|
||||||
|
*
|
||||||
|
* "Originalgröße" bedeutet hier: so groß wie der Bildschirm es zulässt, ohne unnötig über die
|
||||||
|
* tatsächliche Displayauflösung hinaus zu decodieren (s. Kommentar an
|
||||||
|
* [de.medapp.wunddoku.data.repository.DocumentationRepository.loadPhotoFullSize]) - eine echte
|
||||||
|
* 1:1-Pixel-Darstellung würde bei modernen Fotoauflösungen ohnehin über die Bildschirmgröße
|
||||||
|
* hinausgehen und wäre nur per Zoom/Scroll sichtbar, was hier nicht gefordert ist.
|
||||||
|
*
|
||||||
|
* Volle Bildschirmbreite/-höhe via [DialogProperties.usePlatformDefaultWidth] = false, da der
|
||||||
|
* Standard-Dialog-Rahmen von Compose sonst deutlich schmaler als der Bildschirm wäre. Schließen
|
||||||
|
* über den Schließen-Button ODER Antippen des dunklen Hintergrunds.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PhotoViewerDialog(
|
||||||
|
photo: Photo,
|
||||||
|
loadFullSize: suspend (documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) -> Bitmap?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var loadFailed by remember(photo.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
LaunchedEffect(photo.id) {
|
||||||
|
val maxWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
|
||||||
|
val maxHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() }
|
||||||
|
val result = runCatching {
|
||||||
|
loadFullSize(photo.documentationId, photo.fileName, maxWidthPx, maxHeightPx)
|
||||||
|
}.getOrNull()
|
||||||
|
bitmap = result
|
||||||
|
loadFailed = result == null
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.clickable(onClick = onDismiss),
|
||||||
|
) {
|
||||||
|
val currentBitmap = bitmap
|
||||||
|
when {
|
||||||
|
currentBitmap != null -> Image(
|
||||||
|
bitmap = currentBitmap.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
|
||||||
|
loadFailed -> Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.BrokenImage,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.Center).size(48.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.documentation_edit_photo_load_failed),
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_cancel), tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
package de.medapp.wunddoku.ui.documentation.edit.signature
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import de.medapp.wunddoku.R
|
||||||
|
import de.medapp.wunddoku.domain.model.WoundCarer
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog zum Erfassen einer neuen Signatur (MEDIA-003): Name des Wundversorgers + Zeichenfläche
|
||||||
|
* ([SignaturePad]), wie in der Aufgabenstellung (Abschnitt 7) als bevorzugte UX beschrieben -
|
||||||
|
* bewusst als Dialog INNERHALB von [de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen],
|
||||||
|
* keine eigene Navigationsroute.
|
||||||
|
*
|
||||||
|
* Änderungswunsch (Wundversorger-Register): Beim Öffnen wird automatisch der ZULETZT verwendete
|
||||||
|
* Wundversorger samt seiner zuletzt gespeicherten Signatur vorbelegt ([loadDefaultWoundCarer]) -
|
||||||
|
* bleibt aber änderbar. Während der Eingabe werden nach den ersten Buchstaben passende bekannte
|
||||||
|
* Namen als antippbare Vorschläge angezeigt ([searchWoundCarers]); ein Tippen darauf übernimmt
|
||||||
|
* Name UND zuletzt gespeicherte Signatur dieses Wundversorgers ([loadWoundCarerSignatureBitmap]).
|
||||||
|
* In diesem "Vorschau-Modus" wird die geladene Signatur als fertiges Bild angezeigt (nicht mehr
|
||||||
|
* frei bearbeitbar) - über "Neu zeichnen" kann jederzeit zu einer frischen, freihändigen
|
||||||
|
* Zeichnung für denselben Namen gewechselt werden. Wird der Name manuell so verändert, dass er
|
||||||
|
* nicht mehr zum gerade geladenen Vorschlag passt, wird die Vorschau verworfen (verhindert, dass
|
||||||
|
* versehentlich die Signatur einer anderen Person unter einem neuen Namen gespeichert wird).
|
||||||
|
*
|
||||||
|
* "Speichern" ist erst aktiv, wenn BEIDE Bedingungen erfüllt sind (erste Absicherungsebene gegen
|
||||||
|
* leere Signaturen bzw. leeren Namen, s. Aufgabenstellung Abschnitt 12 - die zweite, unabhängige
|
||||||
|
* Ebene sitzt serverseitig in
|
||||||
|
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature]):
|
||||||
|
* - der (getrimmte) Name ist nicht leer
|
||||||
|
* - es liegt entweder eine geladene Vorschau-Signatur ODER eine frisch gezeichnete Signatur vor
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AddSignatureDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (signerName: String, bitmap: Bitmap) -> Unit,
|
||||||
|
loadDefaultWoundCarer: suspend () -> WoundCarer?,
|
||||||
|
searchWoundCarers: suspend (query: String) -> List<WoundCarer>,
|
||||||
|
loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?,
|
||||||
|
) {
|
||||||
|
var signerName by remember { mutableStateOf("") }
|
||||||
|
var strokes by remember { mutableStateOf<List<SignatureStroke>>(emptyList()) }
|
||||||
|
var canvasSize by remember { mutableStateOf(Size.Zero) }
|
||||||
|
|
||||||
|
// != null => Vorschau-Modus: eine bereits bekannte Signatur wird als fertiges Bild gezeigt
|
||||||
|
// statt einer leeren, frei bearbeitbaren Zeichenfläche.
|
||||||
|
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
// Name, zu dem [previewBitmap] gehört - dient ausschließlich dazu, die Vorschau zu verwerfen,
|
||||||
|
// sobald der Name manuell auf etwas anderes geändert wird (s. Klassenkommentar).
|
||||||
|
var previewOwnerName by remember { mutableStateOf<String?>(null) }
|
||||||
|
var suggestions by remember { mutableStateOf<List<WoundCarer>>(emptyList()) }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Einmalige Vorbelegung beim Öffnen mit dem zuletzt verwendeten Wundversorger.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val default = loadDefaultWoundCarer() ?: return@LaunchedEffect
|
||||||
|
signerName = default.name
|
||||||
|
previewOwnerName = default.name
|
||||||
|
previewBitmap = loadWoundCarerSignatureBitmap(default.id, default.signatureFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschläge nachladen UND Vorschau verwerfen, sobald der Name vom Vorschau-Eigentümer
|
||||||
|
// abweicht (z. B. weil der Nutzer den vorbelegten Namen überschreibt).
|
||||||
|
LaunchedEffect(signerName) {
|
||||||
|
if (previewOwnerName != null && signerName != previewOwnerName) {
|
||||||
|
previewBitmap = null
|
||||||
|
previewOwnerName = null
|
||||||
|
}
|
||||||
|
suggestions = if (previewOwnerName == signerName) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
searchWoundCarers(signerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val canSave = signerName.trim().isNotEmpty() && (previewBitmap != null || strokes.isNotEmpty())
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.signature_dialog_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = signerName,
|
||||||
|
onValueChange = { signerName = it },
|
||||||
|
label = { Text(stringResource(R.string.signature_dialog_name_label)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(suggestions, key = { it.id }) { suggestion ->
|
||||||
|
AssistChip(
|
||||||
|
onClick = {
|
||||||
|
signerName = suggestion.name
|
||||||
|
previewOwnerName = suggestion.name
|
||||||
|
suggestions = emptyList()
|
||||||
|
scope.launch {
|
||||||
|
previewBitmap = loadWoundCarerSignatureBitmap(
|
||||||
|
suggestion.id,
|
||||||
|
suggestion.signatureFileName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(suggestion.name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
val currentPreview = previewBitmap
|
||||||
|
if (currentPreview != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White, RoundedCornerShape(8.dp)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = currentPreview.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(180.dp).padding(8.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
previewBitmap = null
|
||||||
|
strokes = emptyList()
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.signature_dialog_redraw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White, RoundedCornerShape(8.dp))
|
||||||
|
.onSizeChanged { size -> canvasSize = Size(size.width.toFloat(), size.height.toFloat()) },
|
||||||
|
) {
|
||||||
|
SignaturePad(
|
||||||
|
strokes = strokes,
|
||||||
|
onStrokesChanged = { strokes = it },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(180.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.signature_dialog_single_stroke_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { strokes = emptyList() }, enabled = strokes.isNotEmpty()) {
|
||||||
|
Text(stringResource(R.string.signature_dialog_reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val bitmap = previewBitmap ?: renderSignatureBitmap(strokes, canvasSize)
|
||||||
|
onSave(signerName.trim(), bitmap)
|
||||||
|
},
|
||||||
|
enabled = canSave,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.signature_dialog_save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package de.medapp.wunddoku.ui.documentation.edit.signature
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
|
||||||
|
/** Logisches Signaturformat, unabhängig von der tatsächlichen Größe der Compose-Zeichenfläche
|
||||||
|
* auf einem konkreten Gerät (Aufgabenstellung Abschnitt 10). NUR für die persistierte Bitmap
|
||||||
|
* relevant - die UI selbst darf/soll responsiv sein. */
|
||||||
|
const val SIGNATURE_LOGICAL_WIDTH = 1000
|
||||||
|
const val SIGNATURE_LOGICAL_HEIGHT = 400
|
||||||
|
|
||||||
|
/** Strichbreite in Bildschirm-dp während des Zeichnens. Die tatsächliche, im PNG persistierte
|
||||||
|
* Strichbreite ergibt sich aus [SIGNATURE_LOGICAL_STROKE_WIDTH] nach der Normalisierung. */
|
||||||
|
private const val SIGNATURE_UI_STROKE_WIDTH_PX = 5f
|
||||||
|
private const val SIGNATURE_LOGICAL_STROKE_WIDTH = 6f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine einzelne, zusammenhängende Zeichenbewegung (ein "Strich") in tatsächlichen
|
||||||
|
* Canvas-Pixelkoordinaten (nicht normalisiert).
|
||||||
|
*/
|
||||||
|
typealias SignatureStroke = List<Offset>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose-basierte Signaturzeichenfläche (MEDIA-003). Unterstützt Finger UND Stylus ohne
|
||||||
|
* Sonderbehandlung - Compose liefert beide über dieselbe Zeigereingabe-Pipeline
|
||||||
|
* ([pointerInput]/[detectDragGestures]), es gibt auf Android keinen technischen Unterschied
|
||||||
|
* zwischen einer Finger- und einer Stylus-Berührung an dieser Stelle.
|
||||||
|
*
|
||||||
|
* FACHLICHE EINSCHRÄNKUNG (Änderungswunsch): Eine gültige Signatur besteht aus GENAU EINEM
|
||||||
|
* durchgehenden Strich - ein erneutes Ansetzen (zweiter Touch-Down, nachdem bereits ein Strich
|
||||||
|
* gezeichnet wurde) verwirft die komplette bisherige Zeichnung automatisch und beginnt neu mit
|
||||||
|
* genau diesem neuen Strich. Es kann also beliebig oft neu versucht werden, aber niemals mehrere
|
||||||
|
* getrennte Striche zu EINER Signatur zusammengesetzt werden. Umgesetzt, indem [onDragEnd] die
|
||||||
|
* nach oben gemeldete Strichliste immer komplett ERSETZT (`listOf(currentStroke)`) statt an
|
||||||
|
* [strokes] anzuhängen - dadurch kann zu jedem Zeitpunkt höchstens ein abgeschlossener Strich in
|
||||||
|
* [strokes] stehen, unabhängig von der Reihenfolge der Ereignisse.
|
||||||
|
*
|
||||||
|
* Zustandslos (state hoisting): [strokes] kommt vom Aufrufer, jede Änderung wird über
|
||||||
|
* [onStrokesChanged] nach oben gemeldet.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SignaturePad(
|
||||||
|
strokes: List<SignatureStroke>,
|
||||||
|
onStrokesChanged: (List<SignatureStroke>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var currentStroke by remember { mutableStateOf<SignatureStroke>(emptyList()) }
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.background(Color.White)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { offset -> currentStroke = listOf(offset) },
|
||||||
|
onDrag = { change, _ ->
|
||||||
|
change.consume()
|
||||||
|
currentStroke = currentStroke + change.position
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
// Ein "Tap" ohne tatsächliche Bewegung (< 2 Punkte) erzeugt bewusst
|
||||||
|
// keinen sichtbaren Strich - eine bereits vorhandene, gültige Signatur
|
||||||
|
// bleibt in diesem Fall unangetastet erhalten. Ein ECHTER Strich ersetzt
|
||||||
|
// dagegen immer die komplette bisherige Zeichnung (s. Klassenkommentar).
|
||||||
|
if (currentStroke.size > 1) {
|
||||||
|
onStrokesChanged(listOf(currentStroke))
|
||||||
|
}
|
||||||
|
currentStroke = emptyList()
|
||||||
|
},
|
||||||
|
onDragCancel = { currentStroke = emptyList() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
for (stroke in strokes) {
|
||||||
|
drawSignatureStroke(stroke)
|
||||||
|
}
|
||||||
|
if (currentStroke.size > 1) {
|
||||||
|
drawSignatureStroke(currentStroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawSignatureStroke(points: SignatureStroke) {
|
||||||
|
if (points.size < 2) return
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(points.first().x, points.first().y)
|
||||||
|
for (index in 1 until points.size) {
|
||||||
|
lineTo(points[index].x, points[index].y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawPath(
|
||||||
|
path = path,
|
||||||
|
color = Color.Black,
|
||||||
|
style = Stroke(
|
||||||
|
width = SIGNATURE_UI_STROKE_WIDTH_PX,
|
||||||
|
cap = StrokeCap.Round,
|
||||||
|
join = StrokeJoin.Round,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert die erfassten [strokes] (in tatsächlichen Canvas-Pixelkoordinaten der Größe
|
||||||
|
* [canvasSize]) auf ein neues, weißes Bitmap im logischen Signaturformat
|
||||||
|
* ([SIGNATURE_LOGICAL_WIDTH] x [SIGNATURE_LOGICAL_HEIGHT]) - unabhängig von der tatsächlichen
|
||||||
|
* Gerätegröße der Zeichenfläche (Aufgabenstellung Abschnitt 10: Normalisierung). Persistiert
|
||||||
|
* wird ausschließlich dieses fertige Bitmap, niemals die Stroke-Rohdaten selbst
|
||||||
|
* (Aufgabenstellung Abschnitt 11).
|
||||||
|
*/
|
||||||
|
fun renderSignatureBitmap(strokes: List<SignatureStroke>, canvasSize: Size): Bitmap {
|
||||||
|
val bitmap = Bitmap.createBitmap(SIGNATURE_LOGICAL_WIDTH, SIGNATURE_LOGICAL_HEIGHT, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = android.graphics.Canvas(bitmap)
|
||||||
|
canvas.drawColor(android.graphics.Color.WHITE)
|
||||||
|
|
||||||
|
if (canvasSize.width <= 0f || canvasSize.height <= 0f) return bitmap
|
||||||
|
|
||||||
|
val scaleX = SIGNATURE_LOGICAL_WIDTH / canvasSize.width
|
||||||
|
val scaleY = SIGNATURE_LOGICAL_HEIGHT / canvasSize.height
|
||||||
|
|
||||||
|
val paint = android.graphics.Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
color = android.graphics.Color.BLACK
|
||||||
|
style = android.graphics.Paint.Style.STROKE
|
||||||
|
strokeWidth = SIGNATURE_LOGICAL_STROKE_WIDTH
|
||||||
|
strokeCap = android.graphics.Paint.Cap.ROUND
|
||||||
|
strokeJoin = android.graphics.Paint.Join.ROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
for (stroke in strokes) {
|
||||||
|
if (stroke.size < 2) continue
|
||||||
|
val path = android.graphics.Path()
|
||||||
|
path.moveTo(stroke.first().x * scaleX, stroke.first().y * scaleY)
|
||||||
|
for (index in 1 until stroke.size) {
|
||||||
|
path.lineTo(stroke[index].x * scaleX, stroke[index].y * scaleY)
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
@@ -1,20 +1,27 @@
|
|||||||
package de.medapp.wunddoku.ui.patients.form
|
package de.medapp.wunddoku.ui.patients.form
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -31,15 +38,23 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.medapp.wunddoku.R
|
import de.medapp.wunddoku.R
|
||||||
|
import de.medapp.wunddoku.domain.model.PatientListItem
|
||||||
|
import de.medapp.wunddoku.ui.components.format.DATE_GROUP_SIZES
|
||||||
|
import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation
|
||||||
|
import de.medapp.wunddoku.ui.components.format.PHONE_GROUP_SIZES
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formular für Schritt 1 einer neuen Wunddokumentation (Aufgabenstellung Abschnitt 7) UND für
|
* Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von
|
||||||
* das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über
|
* Patienten-Stammdaten – unterschieden über [PatientFormViewModel.startsNewDocumentation].
|
||||||
* [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm].
|
|
||||||
*
|
*
|
||||||
* Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT)
|
* Geburtsdatum und Telefonnummern nutzen dieselbe wiederverwendbare
|
||||||
* erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle
|
* [DigitGroupVisualTransformation] (ui/components/format/FormattedInput.kt): Der zugrunde
|
||||||
* Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst.
|
* liegende Zustand im ViewModel bleibt jeweils eine reine Ziffernfolge, die Trennzeichen
|
||||||
|
* (Punkte bzw. Leerzeichen) werden ausschließlich für die Darstellung ergänzt.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -95,6 +110,10 @@ fun PatientFormScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
if (!uiState.isEditing) {
|
||||||
|
ExistingPatientSearchSection(uiState = uiState, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.firstName,
|
value = uiState.firstName,
|
||||||
onValueChange = viewModel::onFirstNameChanged,
|
onValueChange = viewModel::onFirstNameChanged,
|
||||||
@@ -108,42 +127,62 @@ fun PatientFormScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.birthDateText,
|
value = uiState.birthDateDigits,
|
||||||
onValueChange = viewModel::onBirthDateTextChanged,
|
onValueChange = viewModel::onBirthDateDigitsChanged,
|
||||||
label = { Text(stringResource(R.string.patient_form_birth_date)) },
|
label = { Text(stringResource(R.string.patient_form_birth_date)) },
|
||||||
placeholder = { Text("JJJJ-MM-TT") },
|
placeholder = { Text("TT.MM.JJJJ") },
|
||||||
|
visualTransformation = DigitGroupVisualTransformation(DATE_GROUP_SIZES, '.'),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.street,
|
||||||
|
onValueChange = viewModel::onStreetChanged,
|
||||||
|
label = { Text(stringResource(R.string.patient_form_street)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.address,
|
value = uiState.postalCode,
|
||||||
onValueChange = viewModel::onAddressChanged,
|
onValueChange = viewModel::onPostalCodeChanged,
|
||||||
label = { Text(stringResource(R.string.patient_form_address)) },
|
label = { Text(stringResource(R.string.patient_form_postal_code)) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.city,
|
||||||
|
onValueChange = viewModel::onCityChanged,
|
||||||
|
label = { Text(stringResource(R.string.patient_form_city)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.phone1,
|
value = uiState.phone1,
|
||||||
onValueChange = viewModel::onPhone1Changed,
|
onValueChange = viewModel::onPhone1Changed,
|
||||||
label = { Text(stringResource(R.string.patient_form_phone1)) },
|
label = { Text(stringResource(R.string.patient_form_phone1)) },
|
||||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.phone2,
|
value = uiState.phone2,
|
||||||
onValueChange = viewModel::onPhone2Changed,
|
onValueChange = viewModel::onPhone2Changed,
|
||||||
label = { Text(stringResource(R.string.patient_form_phone2)) },
|
label = { Text(stringResource(R.string.patient_form_phone2)) },
|
||||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.phone3,
|
value = uiState.phone3,
|
||||||
onValueChange = viewModel::onPhone3Changed,
|
onValueChange = viewModel::onPhone3Changed,
|
||||||
label = { Text(stringResource(R.string.patient_form_phone3)) },
|
label = { Text(stringResource(R.string.patient_form_phone3)) },
|
||||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
uiState.validationError?.let { error ->
|
uiState.validationError?.let { error ->
|
||||||
Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
|
Text(error, color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -177,3 +216,81 @@ fun PatientFormScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Bestehenden Patienten auswählen" - Suchfeld über der Stammdateneingabe (nur beim Anlegen
|
||||||
|
* eines neuen Patienten sichtbar, nicht beim Bearbeiten eines bereits feststehenden). Wählt der
|
||||||
|
* Nutzer einen Treffer aus, befüllt [PatientFormViewModel.onExistingPatientSelected] das
|
||||||
|
* restliche Formular automatisch; die Auswahl selbst wird als Chip mit "Aufheben"-Möglichkeit
|
||||||
|
* dargestellt, solange sie aktiv ist.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExistingPatientSearchSection(
|
||||||
|
uiState: PatientFormUiState,
|
||||||
|
viewModel: PatientFormViewModel,
|
||||||
|
) {
|
||||||
|
val selectedId = uiState.selectedExistingPatientId
|
||||||
|
if (selectedId != null) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text("Bestehender Patient ausgewählt", style = MaterialTheme.typography.labelMedium)
|
||||||
|
Text("${uiState.firstName} ${uiState.lastName}", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
IconButton(onClick = viewModel::clearSelectedExistingPatient) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "Auswahl aufheben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.patientSearchQuery,
|
||||||
|
onValueChange = viewModel::onPatientSearchQueryChanged,
|
||||||
|
label = { Text("Bestehenden Patienten auswählen") },
|
||||||
|
placeholder = { Text("Name oder Geburtsdatum suchen") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.patientSearchQuery.isNotBlank()) {
|
||||||
|
if (uiState.patientSearchResults.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Keine passenden Patienten gefunden.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column {
|
||||||
|
uiState.patientSearchResults.forEachIndexed { index, item ->
|
||||||
|
if (index > 0) HorizontalDivider()
|
||||||
|
PatientSearchResultRow(item = item, onClick = { viewModel.onExistingPatientSelected(item.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PatientSearchResultRow(item: PatientListItem, onClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(item.fullName, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
"${item.birthDate.format(dateFormatter)} · ${item.displayAddress}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,29 +10,55 @@ import de.medapp.wunddoku.data.repository.DocumentationRepository
|
|||||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||||
import de.medapp.wunddoku.di.wunddokuApplication
|
import de.medapp.wunddoku.di.wunddokuApplication
|
||||||
import de.medapp.wunddoku.domain.model.Patient
|
import de.medapp.wunddoku.domain.model.Patient
|
||||||
|
import de.medapp.wunddoku.domain.model.PatientListItem
|
||||||
|
import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS
|
||||||
|
import de.medapp.wunddoku.ui.components.format.PHONE_MAX_DIGITS
|
||||||
|
import de.medapp.wunddoku.ui.components.format.POSTAL_CODE_MAX_DIGITS
|
||||||
|
import de.medapp.wunddoku.ui.components.format.filterDigits
|
||||||
|
import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull
|
||||||
|
import de.medapp.wunddoku.ui.components.format.toGermanDateDigits
|
||||||
import de.medapp.wunddoku.ui.navigation.Screen
|
import de.medapp.wunddoku.ui.navigation.Screen
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
data class PatientFormUiState(
|
data class PatientFormUiState(
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val firstName: String = "",
|
val firstName: String = "",
|
||||||
val lastName: String = "",
|
val lastName: String = "",
|
||||||
/** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */
|
/** Rohe Ziffernfolge (max. 8 Zeichen, TTMMJJJJ) - die Formatierung TT.MM.JJJJ erfolgt
|
||||||
val birthDateText: String = "",
|
* ausschließlich über eine VisualTransformation in der Compose-UI, s.
|
||||||
val address: String = "",
|
* ui/components/format/FormattedInput.kt. */
|
||||||
|
val birthDateDigits: String = "",
|
||||||
|
val street: String = "",
|
||||||
|
/** Rohe Ziffernfolge, max. 5 Zeichen (deutsche PLZ). */
|
||||||
|
val postalCode: String = "",
|
||||||
|
val city: String = "",
|
||||||
|
/** Rohe Ziffernfolgen (führende Nullen bleiben erhalten, da als String geführt) - die
|
||||||
|
* Leerzeichen-Formatierung erfolgt ausschließlich über eine VisualTransformation in der UI. */
|
||||||
val phone1: String = "",
|
val phone1: String = "",
|
||||||
val phone2: String = "",
|
val phone2: String = "",
|
||||||
val phone3: String = "",
|
val phone3: String = "",
|
||||||
val validationError: String? = null,
|
val validationError: String? = null,
|
||||||
/** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */
|
/** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */
|
||||||
val duplicateWarning: Patient? = null,
|
val duplicateWarning: Patient? = null,
|
||||||
|
/** Suchfeld "bestehenden Patienten auswählen" - nur relevant, solange isEditing = false. */
|
||||||
|
val patientSearchQuery: String = "",
|
||||||
|
val patientSearchResults: List<PatientListItem> = emptyList(),
|
||||||
|
/**
|
||||||
|
* != null, sobald der Nutzer über die Suche einen bestehenden Patienten ausgewählt hat.
|
||||||
|
* Das Formular ist dann mit dessen Daten vorbefüllt; Speichern aktualisiert diesen
|
||||||
|
* Patienten, statt einen neuen anzulegen, und überspringt die separate
|
||||||
|
* Duplikat-Erkennung (der Nutzer hat den Patienten bereits bewusst gewählt).
|
||||||
|
*/
|
||||||
|
val selectedExistingPatientId: String? = null,
|
||||||
/** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */
|
/** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */
|
||||||
val patientSaved: Boolean = false,
|
val patientSaved: Boolean = false,
|
||||||
/** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */
|
/** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */
|
||||||
@@ -40,8 +66,7 @@ data class PatientFormUiState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab
|
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab.
|
||||||
* (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6).
|
|
||||||
*/
|
*/
|
||||||
class PatientFormViewModel(
|
class PatientFormViewModel(
|
||||||
private val patientId: String?,
|
private val patientId: String?,
|
||||||
@@ -53,7 +78,12 @@ class PatientFormViewModel(
|
|||||||
private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null))
|
private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null))
|
||||||
val uiState: StateFlow<PatientFormUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PatientFormUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
/** Getrennt vom UiState-Textfeld geführt, damit flatMapLatest die Suche sauber debouncen/
|
||||||
|
* abbrechen kann, ohne den restlichen UiState anzufassen. */
|
||||||
|
private val patientSearchQuery = MutableStateFlow("")
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
observePatientSearch()
|
||||||
if (patientId != null) {
|
if (patientId != null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val patient = repository.getById(patientId)
|
val patient = repository.getById(patientId)
|
||||||
@@ -63,8 +93,10 @@ class PatientFormViewModel(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
firstName = patient.firstName,
|
firstName = patient.firstName,
|
||||||
lastName = patient.lastName,
|
lastName = patient.lastName,
|
||||||
birthDateText = patient.birthDate.toString(),
|
birthDateDigits = patient.birthDate.toGermanDateDigits(),
|
||||||
address = patient.address,
|
street = patient.street,
|
||||||
|
postalCode = patient.postalCode,
|
||||||
|
city = patient.city,
|
||||||
phone1 = patient.phone1,
|
phone1 = patient.phone1,
|
||||||
phone2 = patient.phone2.orEmpty(),
|
phone2 = patient.phone2.orEmpty(),
|
||||||
phone3 = patient.phone3.orEmpty(),
|
phone3 = patient.phone3.orEmpty(),
|
||||||
@@ -81,11 +113,73 @@ class PatientFormViewModel(
|
|||||||
|
|
||||||
fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) }
|
fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) }
|
||||||
fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) }
|
fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) }
|
||||||
fun onBirthDateTextChanged(value: String) = _uiState.update { it.copy(birthDateText = value) }
|
|
||||||
fun onAddressChanged(value: String) = _uiState.update { it.copy(address = value) }
|
fun onBirthDateDigitsChanged(raw: String) =
|
||||||
fun onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) }
|
_uiState.update { it.copy(birthDateDigits = filterDigits(raw, DATE_MAX_DIGITS)) }
|
||||||
fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) }
|
|
||||||
fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) }
|
fun onStreetChanged(value: String) = _uiState.update { it.copy(street = value) }
|
||||||
|
|
||||||
|
fun onPostalCodeChanged(raw: String) =
|
||||||
|
_uiState.update { it.copy(postalCode = filterDigits(raw, POSTAL_CODE_MAX_DIGITS)) }
|
||||||
|
|
||||||
|
fun onCityChanged(value: String) = _uiState.update { it.copy(city = value) }
|
||||||
|
|
||||||
|
fun onPhone1Changed(raw: String) =
|
||||||
|
_uiState.update { it.copy(phone1 = filterDigits(raw, PHONE_MAX_DIGITS)) }
|
||||||
|
|
||||||
|
fun onPhone2Changed(raw: String) =
|
||||||
|
_uiState.update { it.copy(phone2 = filterDigits(raw, PHONE_MAX_DIGITS)) }
|
||||||
|
|
||||||
|
fun onPhone3Changed(raw: String) =
|
||||||
|
_uiState.update { it.copy(phone3 = filterDigits(raw, PHONE_MAX_DIGITS)) }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private fun observePatientSearch() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
patientSearchQuery
|
||||||
|
.flatMapLatest { query -> if (query.isBlank()) flowOf(emptyList()) else repository.search(query) }
|
||||||
|
.collect { results -> _uiState.update { it.copy(patientSearchResults = results) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPatientSearchQueryChanged(query: String) {
|
||||||
|
patientSearchQuery.value = query
|
||||||
|
_uiState.update { it.copy(patientSearchQuery = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Nutzer wählt im Suchfeld "bestehenden Patienten auswählen" einen Treffer aus - das
|
||||||
|
* restliche Formular wird automatisch mit dessen aktuellen Stammdaten befüllt.
|
||||||
|
*/
|
||||||
|
fun onExistingPatientSelected(patientId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val patient = repository.getById(patientId) ?: return@launch
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
selectedExistingPatientId = patient.id,
|
||||||
|
firstName = patient.firstName,
|
||||||
|
lastName = patient.lastName,
|
||||||
|
birthDateDigits = patient.birthDate.toGermanDateDigits(),
|
||||||
|
street = patient.street,
|
||||||
|
postalCode = patient.postalCode,
|
||||||
|
city = patient.city,
|
||||||
|
phone1 = patient.phone1,
|
||||||
|
phone2 = patient.phone2.orEmpty(),
|
||||||
|
phone3 = patient.phone3.orEmpty(),
|
||||||
|
patientSearchQuery = "",
|
||||||
|
patientSearchResults = emptyList(),
|
||||||
|
validationError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
patientSearchQuery.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hebt eine per Suche getroffene Auswahl wieder auf - die Formularfelder bleiben editierbar
|
||||||
|
* erhalten (Speichern legt dann einen NEUEN Patienten mit den aktuell eingetragenen Werten an). */
|
||||||
|
fun clearSelectedExistingPatient() {
|
||||||
|
_uiState.update { it.copy(selectedExistingPatientId = null) }
|
||||||
|
}
|
||||||
|
|
||||||
fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
|
fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
|
||||||
|
|
||||||
@@ -105,23 +199,27 @@ class PatientFormViewModel(
|
|||||||
fun save(skipDuplicateCheck: Boolean = false) {
|
fun save(skipDuplicateCheck: Boolean = false) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
|
|
||||||
val birthDate = try {
|
val birthDate = germanDateDigitsToLocalDateOrNull(state.birthDateDigits)
|
||||||
LocalDate.parse(state.birthDateText.trim())
|
if (birthDate == null) {
|
||||||
} catch (e: Exception) {
|
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum eingeben (TT.MM.JJJJ).") }
|
||||||
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state.firstName.isBlank() || state.lastName.isBlank()) {
|
if (state.firstName.isBlank() || state.lastName.isBlank()) {
|
||||||
_uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") }
|
_uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state.address.isBlank() || state.phone1.isBlank()) {
|
if (state.street.isBlank() || state.postalCode.isBlank() || state.city.isBlank()) {
|
||||||
_uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") }
|
_uiState.update { it.copy(validationError = "Straße, PLZ und Stadt dürfen nicht leer sein.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.phone1.isBlank()) {
|
||||||
|
_uiState.update { it.copy(validationError = "Telefonnummer 1 darf nicht leer sein.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (!state.isEditing && !skipDuplicateCheck) {
|
val skipBecauseExplicitlySelected = state.selectedExistingPatientId != null
|
||||||
|
if (!state.isEditing && !skipDuplicateCheck && !skipBecauseExplicitlySelected) {
|
||||||
val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate)
|
val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate)
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
_uiState.update { it.copy(duplicateWarning = existing, validationError = null) }
|
_uiState.update { it.copy(duplicateWarning = existing, validationError = null) }
|
||||||
@@ -130,14 +228,16 @@ class PatientFormViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val patient = Patient(
|
val patient = Patient(
|
||||||
id = patientId ?: repository.newId(),
|
id = patientId ?: state.selectedExistingPatientId ?: repository.newId(),
|
||||||
firstName = state.firstName.trim(),
|
firstName = state.firstName.trim(),
|
||||||
lastName = state.lastName.trim(),
|
lastName = state.lastName.trim(),
|
||||||
birthDate = birthDate,
|
birthDate = birthDate,
|
||||||
address = state.address.trim(),
|
street = state.street.trim(),
|
||||||
phone1 = state.phone1.trim(),
|
postalCode = state.postalCode.trim(),
|
||||||
phone2 = state.phone2.trim().ifBlank { null },
|
city = state.city.trim(),
|
||||||
phone3 = state.phone3.trim().ifBlank { null },
|
phone1 = state.phone1,
|
||||||
|
phone2 = state.phone2.ifBlank { null },
|
||||||
|
phone3 = state.phone3.ifBlank { null },
|
||||||
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
|
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
|
||||||
updatedAt = Instant.EPOCH,
|
updatedAt = Instant.EPOCH,
|
||||||
)
|
)
|
||||||
@@ -150,8 +250,8 @@ class PatientFormViewModel(
|
|||||||
/**
|
/**
|
||||||
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
|
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
|
||||||
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
|
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
|
||||||
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder
|
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder nur
|
||||||
* nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
|
* signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
|
||||||
*/
|
*/
|
||||||
private suspend fun finish(resolvedPatientId: String) {
|
private suspend fun finish(resolvedPatientId: String) {
|
||||||
if (startsNewDocumentation) {
|
if (startsNewDocumentation) {
|
||||||
@@ -166,10 +266,6 @@ class PatientFormViewModel(
|
|||||||
fun factory() = viewModelFactory {
|
fun factory() = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
val container = wunddokuApplication().container
|
val container = wunddokuApplication().container
|
||||||
// createSavedStateHandle() ist die von Google empfohlene Art, innerhalb eines
|
|
||||||
// viewModelFactory-Initializers an das SavedStateHandle zu kommen; toRoute()
|
|
||||||
// dekodiert daraus direkt die typsichere Navigations-Route (Screen.PatientForm),
|
|
||||||
// statt Argumente manuell als lose Strings auszulesen.
|
|
||||||
val savedStateHandle = createSavedStateHandle()
|
val savedStateHandle = createSavedStateHandle()
|
||||||
val route = savedStateHandle.toRoute<Screen.PatientForm>()
|
val route = savedStateHandle.toRoute<Screen.PatientForm>()
|
||||||
PatientFormViewModel(
|
PatientFormViewModel(
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ private fun PatientListItemCard(
|
|||||||
"Geburtsdatum: ${item.birthDate.format(dateFormatter)}",
|
"Geburtsdatum: ${item.birthDate.format(dateFormatter)}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
Text(item.address, style = MaterialTheme.typography.bodySmall)
|
Text(item.displayAddress, style = MaterialTheme.typography.bodySmall)
|
||||||
val lastDoc = item.lastDocumentationAt
|
val lastDoc = item.lastDocumentationAt
|
||||||
if (lastDoc != null) {
|
if (lastDoc != null) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -24,17 +24,38 @@
|
|||||||
<string name="patient_form_first_name">Vorname</string>
|
<string name="patient_form_first_name">Vorname</string>
|
||||||
<string name="patient_form_last_name">Nachname</string>
|
<string name="patient_form_last_name">Nachname</string>
|
||||||
<string name="patient_form_birth_date">Geburtsdatum</string>
|
<string name="patient_form_birth_date">Geburtsdatum</string>
|
||||||
<string name="patient_form_address">Adresse</string>
|
<string name="patient_form_street">Straße & Hausnummer</string>
|
||||||
|
<string name="patient_form_postal_code">PLZ</string>
|
||||||
|
<string name="patient_form_city">Stadt</string>
|
||||||
<string name="patient_form_phone1">Telefonnummer 1</string>
|
<string name="patient_form_phone1">Telefonnummer 1</string>
|
||||||
<string name="patient_form_phone2">Telefonnummer 2 (optional)</string>
|
<string name="patient_form_phone2">Telefonnummer 2 (optional)</string>
|
||||||
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
|
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
|
||||||
<string name="patient_form_save">Speichern</string>
|
<string name="patient_form_save">Speichern</string>
|
||||||
|
|
||||||
<string name="documentation_edit_position">%1$d / %2$d</string>
|
<string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
|
||||||
<string name="documentation_edit_previous">Zurück</string>
|
<string name="documentation_edit_block_edited">Block bearbeitet</string>
|
||||||
<string name="documentation_edit_next">Weiter</string>
|
|
||||||
<string name="documentation_edit_free_text_label">Freitext</string>
|
<string name="documentation_edit_free_text_label">Freitext</string>
|
||||||
|
|
||||||
|
<string name="documentation_edit_photos_title">Fotos</string>
|
||||||
|
<string name="documentation_edit_add_photos">Hinzufügen</string>
|
||||||
|
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
|
||||||
|
<string name="documentation_edit_photo_load_failed">Foto konnte nicht geladen werden.</string>
|
||||||
|
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
|
||||||
|
<string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
|
||||||
|
|
||||||
|
<string name="documentation_edit_signatures_title">Unterschriften</string>
|
||||||
|
<string name="documentation_edit_add_signature">Signatur hinzufügen</string>
|
||||||
|
<string name="documentation_edit_no_signatures">Noch keine Unterschriften vorhanden.</string>
|
||||||
|
<string name="documentation_edit_delete_signature_confirm_title">Unterschrift löschen?</string>
|
||||||
|
<string name="documentation_edit_delete_signature_confirm_text">Diese Unterschrift wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
|
||||||
|
|
||||||
|
<string name="signature_dialog_title">Signatur hinzufügen</string>
|
||||||
|
<string name="signature_dialog_name_label">Name des Wundversorgers</string>
|
||||||
|
<string name="signature_dialog_reset">Zurücksetzen</string>
|
||||||
|
<string name="signature_dialog_save">Speichern</string>
|
||||||
|
<string name="signature_dialog_redraw">Neu zeichnen</string>
|
||||||
|
<string name="signature_dialog_single_stroke_hint">Bitte in einem Zug unterschreiben, ohne abzusetzen - bei erneutem Ansetzen wird die Zeichnung zurückgesetzt.</string>
|
||||||
|
|
||||||
<string name="action_cancel">Abbrechen</string>
|
<string name="action_cancel">Abbrechen</string>
|
||||||
<string name="action_confirm">Bestätigen</string>
|
<string name="action_confirm">Bestätigen</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
|||||||
Reference in New Issue
Block a user