diff --git a/.gitignore b/.gitignore index 798292f..27e9ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ local.properties .DS_Store Thumbs.db -# Android profiling +# Android proFiling *.hprof # Signing keys diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f93742f..9652fc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,4 +153,4 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.compose.ui:ui-test-junit4") -} \ No newline at end of file +} diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/2.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/2.json new file mode 100644 index 0000000..bf33c32 --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/2.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json new file mode 100644 index 0000000..15577b4 --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json new file mode 100644 index 0000000..c28d05f --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/5.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/5.json new file mode 100644 index 0000000..f42f89f --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/5.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/6.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/6.json new file mode 100644 index 0000000..6db2ec0 --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/6.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt index 275ae25..f8e099c 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt @@ -7,9 +7,7 @@ import de.medapp.wunddoku.data.entity.BlockStateEntity /** * Bewusst schlanke DAO: Nur Datenbankzugriffe, keine Koordination mehrerer Tabellen (das - * übernimmt DocumentationRepository). Kein `update` für ganze Entities nötig, da nur - * `freeText`/`updatedAt` sich nach dem Erstellen je ändern (Autosave) – ein gezieltes - * Teil-Update ist hier klarer als ein vollständiges Entity-Update. + * übernimmt DocumentationRepository). */ @Dao interface BlockStateDao { @@ -22,7 +20,4 @@ interface BlockStateDao { @Query("SELECT * FROM block_states WHERE documentationId = :documentationId ORDER BY blockOrder") suspend fun getForDocumentation(documentationId: String): List - - @Query("UPDATE block_states SET freeText = :freeText, updatedAt = :updatedAt WHERE id = :id") - suspend fun updateFreeText(id: String, freeText: String, updatedAt: Long) } diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt index 92394ba..abb0e5c 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt @@ -16,4 +16,8 @@ interface CheckItemStateDao { @Query("UPDATE check_item_states SET checked = :checked WHERE id = :id") 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) } diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt index cddd351..0ceafb9 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt @@ -8,7 +8,6 @@ import androidx.room.Transaction import androidx.room.Update import de.medapp.wunddoku.data.entity.DocumentationEntity import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails -import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient import kotlinx.coroutines.flow.Flow @Dao @@ -27,15 +26,22 @@ interface DocumentationDao { suspend fun getById(id: String): DocumentationEntity? /** - * Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen mit - * Patientenbezug, ohne die vollständigen Blöcke zu laden – bewusst schlank für Performance - * bei größeren Datenmengen. + * Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen. + * Liefert direkt [DocumentationEntity] statt einer Relation mit Patient - Name/Adresse für + * 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") - fun observeAllWithPatient(): Flow> + fun observeAll(): Flow> - @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( """ SELECT d.* FROM documentations d @@ -46,7 +52,7 @@ interface DocumentationDao { ORDER BY d.createdAt DESC """ ) - fun search(query: String): Flow> + fun search(query: String): Flow> @Transaction @Query("SELECT * FROM documentations WHERE id = :id") diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt index 7d5a682..60f973d 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt @@ -18,6 +18,13 @@ interface SignatureDao { @Query("SELECT * FROM signatures WHERE id = :id") 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 + + /** 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 } diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt new file mode 100644 index 0000000..6766c2f --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt @@ -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 + + /** 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? +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt index 71461fa..2e593a2 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt @@ -11,18 +11,48 @@ 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.SignatureDao +import de.medapp.wunddoku.data.dao.WoundCarerDao import de.medapp.wunddoku.data.entity.BlockStateEntity import de.medapp.wunddoku.data.entity.CheckItemStateEntity 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.SignatureEntity +import de.medapp.wunddoku.data.entity.WoundCarerEntity import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter /** * Zentrale Room-Datenbank. `exportSchema = true`, damit Room die Schema-Historie als JSON in - * app/schemas/ ablegt (im Gradle-Skript über `room { schemaDirectory(...) }` zu konfigurieren, - * s. Kommentar unten) – das ist die Grundlage für spätere, nachvollziehbare Migrationen. + * app/schemas/ ablegt – Grundlage für 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( entities = [ @@ -32,8 +62,9 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter CheckItemStateEntity::class, PhotoEntity::class, SignatureEntity::class, + WoundCarerEntity::class, ], - version = 1, + version = 6, exportSchema = true, ) @TypeConverters(DocumentationStatusConverter::class) @@ -45,6 +76,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun checkItemStateDao(): CheckItemStateDao abstract fun photoDao(): PhotoDao abstract fun signatureDao(): SignatureDao + abstract fun woundCarerDao(): WoundCarerDao companion object { private const val DATABASE_NAME = "wunddoku.db" @@ -58,7 +90,10 @@ abstract class AppDatabase : RoomDatabase() { context.applicationContext, AppDatabase::class.java, 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 } } } } diff --git a/app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt b/app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt new file mode 100644 index 0000000..9d0a48c --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt @@ -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 ''" + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt index 5e476e5..a23326d 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt @@ -15,9 +15,13 @@ import androidx.room.PrimaryKey * Foreign Key: Der Katalog ist keine Datenbanktabelle, sondern Kotlin-Code. * * [titleSnapshot]/[blockOrder] speichern den zum Erstellungszeitpunkt der Dokumentation - * gültigen Titel bzw. die gültige Reihenfolge (Architektur-Dokument Abschnitt 2.4) – das - * stellt sicher, dass eine später geöffnete, historische Dokumentation unverändert korrekt - * dargestellt wird, selbst wenn sich der Katalog danach ändert. + * gültigen Titel bzw. die gültige Reihenfolge - das stellt sicher, dass eine später geöffnete, + * historische Dokumentation unverändert korrekt dargestellt wird, selbst wenn sich der Katalog + * 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( tableName = "block_states", @@ -40,6 +44,5 @@ data class BlockStateEntity( val blockDefinitionId: String, val titleSnapshot: String, val blockOrder: Int, - val freeText: String, val updatedAt: Long, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt index 9494753..a8515bf 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt @@ -10,6 +10,32 @@ import androidx.room.PrimaryKey * * [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche * 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( tableName = "check_item_states", @@ -33,4 +59,8 @@ data class CheckItemStateEntity( val labelSnapshot: String, val itemOrder: Int, val checked: Boolean, + val isFreeText: Boolean = false, + val freeTextValue: String = "", + val isStaticHint: Boolean = false, + val inputSpec: String = "", ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt index f30125c..034f3e1 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt @@ -49,4 +49,13 @@ data class DocumentationEntity( val continuedFromDocumentationId: String?, val pdfLastGeneratedAt: Long?, 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, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt index d98dd2d..1670f4e 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt @@ -13,6 +13,9 @@ import androidx.room.PrimaryKey * TypeConverter auf LocalDate: Der String ist bereits das native SQLite-Format, direkt * sortierbar und ohne Konverter lesbar. Der Mapper (PatientMapper) übernimmt die Umwandlung * 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( tableName = "patients", @@ -26,7 +29,9 @@ data class PatientEntity( val firstName: String, val lastName: String, val birthDate: String, - val address: String, + val street: String, + val postalCode: String, + val city: String, val phone1: String, val phone2: String?, val phone3: String?, diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt index 9520bae..9f3148e 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt @@ -7,9 +7,14 @@ import androidx.room.PrimaryKey /** * 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 - * (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter - * context.filesDir/signatures/ – kein BLOB in SQLite. + * DocumentationEntity, damit eine Dokumentation beliebig viele Unterschriften unterschiedlicher + * Wundversorger besitzen kann (1:n, MEDIA-004). [fileName] verweist auf eine PNG-Datei unter + * context.filesDir/signatures// – 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( tableName = "signatures", @@ -28,7 +33,10 @@ import androidx.room.PrimaryKey data class SignatureEntity( @PrimaryKey val id: 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, + /** Stabile Reihenfolge mehrerer Signaturen einer Dokumentation, analog [de.medapp.wunddoku.data.entity.PhotoEntity.orderIndex]. */ + val orderIndex: Int, val createdAt: Long, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt new file mode 100644 index 0000000..c5c49fd --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt @@ -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, +) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt index 752aea7..8bef5ab 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt @@ -5,7 +5,6 @@ import androidx.room.Relation import de.medapp.wunddoku.data.entity.BlockStateEntity import de.medapp.wunddoku.data.entity.CheckItemStateEntity 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.SignatureEntity @@ -56,16 +55,3 @@ data class DocumentationWithDetails( ) val signatures: List, ) - -/** - * 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, -) diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt index a4812f1..6ca86c7 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt @@ -1,26 +1,26 @@ 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.DocumentationEntity import de.medapp.wunddoku.data.entity.relations.BlockStateWithItems 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.Documentation import de.medapp.wunddoku.domain.model.DocumentationBlock import de.medapp.wunddoku.domain.model.DocumentationSummary +import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot import java.time.Instant +import java.time.LocalDate /** - * Wandelt zwischen den Room-Relations und den Domain-Modellen [Documentation]/ - * [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern - * ([BlockStateEntity.titleSnapshot], [CheckItemStateEntity.labelSnapshot]) – hier findet KEINE - * Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt (Architektur- - * Dokument Abschnitt 2.4). + * Wandelt zwischen den Room-Relations/Entities und den Domain-Modellen [Documentation]/ + * [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern - hier + * findet KEINE Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt. */ fun DocumentationWithDetails.toDomain(): Documentation = Documentation( id = documentation.id, patientId = documentation.patientId, + patientSnapshot = documentation.toPatientSnapshot(), status = documentation.status, createdAt = Instant.ofEpochMilli(documentation.createdAt), updatedAt = Instant.ofEpochMilli(documentation.updatedAt), @@ -40,15 +40,18 @@ fun DocumentationWithDetails.toDomain(): Documentation = Documentation( createdAt = Instant.ofEpochMilli(it.createdAt), ) }, - signatures = signatures.map { - de.medapp.wunddoku.domain.model.Signature( - id = it.id, - documentationId = it.documentationId, - signerName = it.signerName, - fileName = it.fileName, - createdAt = Instant.ofEpochMilli(it.createdAt), - ) - }, + signatures = signatures + .sortedBy { it.orderIndex } + .map { + de.medapp.wunddoku.domain.model.Signature( + id = it.id, + documentationId = it.documentationId, + signerName = it.signerName, + fileName = it.fileName, + orderIndex = it.orderIndex, + createdAt = Instant.ofEpochMilli(it.createdAt), + ) + }, ) fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock( @@ -56,7 +59,6 @@ fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock( definitionId = blockState.blockDefinitionId, title = blockState.titleSnapshot, order = blockState.blockOrder, - freeText = blockState.freeText, items = checkItems .sortedBy { it.itemOrder } .map { it.toDomain() }, @@ -68,13 +70,31 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem( label = labelSnapshot, order = itemOrder, checked = checked, + isFreeText = isFreeText, + isStaticHint = isStaticHint, + inputSpec = inputSpec, + freeTextValue = freeTextValue, ) -fun DocumentationWithPatient.toSummary(): DocumentationSummary = DocumentationSummary( - id = documentation.id, - patientId = documentation.patientId, - patientFullName = "${patient.firstName} ${patient.lastName}", - status = documentation.status, - createdAt = Instant.ofEpochMilli(documentation.createdAt), - updatedAt = Instant.ofEpochMilli(documentation.updatedAt), +/** + * Liefert direkt aus [DocumentationEntity] eine [DocumentationSummary] für Listenansichten - + * ohne Join/Relation mit der Patient-Tabelle, da Name für die Anzeige bereits als Snapshot auf + * der Dokumentation selbst liegt (siehe PatientStammdatenSnapshot-Dokumentation). + */ +fun DocumentationEntity.toSummary(): DocumentationSummary = DocumentationSummary( + 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, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt index 4a3d881..4a3fdff 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt @@ -10,16 +10,16 @@ import java.time.LocalDate * * 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 - * oder von ihr zu erben. Der Aufruf sieht dadurch wie eine normale Methode aus: - * `patientEntity.toDomain()`. Praktisch, um Mapping-Logik nahe an den beteiligten Typen zu - * halten, ohne eine eigene Mapper-Klasse mit Konstruktor-Injection zu benötigen. + * oder von ihr zu erben. */ fun PatientEntity.toDomain(): Patient = Patient( id = id, firstName = firstName, lastName = lastName, birthDate = LocalDate.parse(birthDate), - address = address, + street = street, + postalCode = postalCode, + city = city, phone1 = phone1, phone2 = phone2, phone3 = phone3, @@ -32,7 +32,9 @@ fun Patient.toEntity(): PatientEntity = PatientEntity( firstName = firstName, lastName = lastName, birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd) - address = address, + street = street, + postalCode = postalCode, + city = city, phone1 = phone1, phone2 = phone2, phone3 = phone3, diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt index 30abece..9650ce0 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt @@ -6,6 +6,7 @@ import androidx.room.withTransaction import de.medapp.wunddoku.data.dao.BlockStateDao import de.medapp.wunddoku.data.dao.CheckItemStateDao 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.SignatureDao 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.DocumentationStatus 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.Signature +import de.medapp.wunddoku.media.ImageDownsampler 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.map +import java.io.File import java.time.Instant import java.util.UUID @@ -33,7 +44,14 @@ import java.util.UUID * Koordiniert alle Lese-/Schreiboperationen rund um eine Wunddokumentation über mehrere DAOs * hinweg. ViewModels sehen niemals mehr als dieses eine Repository für Dokumentations-bezogene * 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( private val documentationDao: DocumentationDao, @@ -41,13 +59,17 @@ class DocumentationRepository( private val checkItemStateDao: CheckItemStateDao, private val photoDao: PhotoDao, private val signatureDao: SignatureDao, + private val patientDao: PatientDao, 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, ) { fun observeAll(): Flow> = - documentationDao.observeAllWithPatient().map { list -> list.map { it.toSummary() } } + documentationDao.observeAll().map { list -> list.map { it.toSummary() } } fun search(query: String): Flow> = 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] - * plus je einen [BlockStateEntity] pro Katalog-Block (mit Label-/Titel-Snapshot aus dem - * *aktuellen* Katalogstand, Architektur-Dokument Abschnitt 2.4) und je einen - * [CheckItemStateEntity] pro Katalog-Checkbox. Läuft in einer einzigen Room-Transaktion, - * damit niemals eine halb angelegte Dokumentation (z. B. Documentation-Zeile ohne - * zugehörige Blöcke) entstehen kann. + * (inkl. frischem Patienten-Stammdaten-Snapshot aus der aktuellen Patient-Tabelle) plus je + * einen [BlockStateEntity] pro Katalog-Block (mit Titel-/Reihenfolge-Snapshot aus dem + * *aktuellen* Katalogstand) und je einen [CheckItemStateEntity] pro Katalog-Checkbox, PLUS + * eine zusätzliche Freitext-Option pro Block (isFreeText = true). Läuft in einer einzigen + * Room-Transaktion, damit niemals eine halb angelegte Dokumentation entstehen kann. */ suspend fun createEmpty(patientId: String): Documentation = db.withTransaction { + val patient = patientDao.getById(patientId) + ?: error("Patient $patientId nicht gefunden") val now = Instant.now().toEpochMilli() val documentationId = UUID.randomUUID().toString() @@ -77,6 +101,12 @@ class DocumentationRepository( continuedFromDocumentationId = null, pdfLastGeneratedAt = null, 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- - * Dokument Abschnitt 6): + * "Dokumentation fortsetzen": * - Patient: gleiche patientId, kein neuer Patientendatensatz - * - BlockState/CheckItemState: vollständig von der Quelle kopiert, inkl. Snapshots und - * Reihenfolge der QUELLE (nicht neu aus dem aktuellen Katalog gezogen) – die fortgesetzte - * Dokumentation beginnt exakt auf dem inhaltlichen Stand des Vorgängers. + * - Patienten-Stammdaten-Snapshot: wird FRISCH aus dem *aktuellen* Patientendatensatz + * gebildet (nicht von der Quelldokumentation kopiert) - die neue, fortgesetzte + * 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 * - status: IN_PROGRESS * - continuedFromDocumentationId: ID der Quelle - * - Unterschriften: werden NICHT kopiert (eine Unterschrift bestätigt nur die konkrete - * Dokumentation, unter der sie geleistet wurde) - * - Fotos: über [copyPhotos] steuerbar, Standard `false` – fachlich noch nicht final - * entschieden (siehe Architektur-Dokument Abschnitt 6) + * - Unterschriften: werden NICHT kopiert + * - Fotos: werden standardmäßig mit übernommen (physisch eigenständige Kopien pro + * Dokumentation, s. [PhotoStorage.duplicate]) - fachliche Entscheidung, ursprünglich als + * offen markiert, inzwischen bewusst so festgelegt. Über [copyPhotos] weiterhin + * abschaltbar, falls dafür später doch ein Anwendungsfall entsteht. */ suspend fun continueDocumentation( sourceDocumentationId: String, - copyPhotos: Boolean = false, + copyPhotos: Boolean = true, ): Documentation = db.withTransaction { val source = documentationDao.getFullDocumentation(sourceDocumentationId) ?: 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 newDocumentationId = UUID.randomUUID().toString() @@ -120,10 +156,16 @@ class DocumentationRepository( continuedFromDocumentationId = sourceDocumentationId, pdfLastGeneratedAt = null, 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) { val newBlockStateId = UUID.randomUUID().toString() blockStateDao.insertAll( @@ -134,7 +176,6 @@ class DocumentationRepository( blockDefinitionId = sourceBlock.blockState.blockDefinitionId, titleSnapshot = sourceBlock.blockState.titleSnapshot, blockOrder = sourceBlock.blockState.blockOrder, - freeText = sourceBlock.blockState.freeText, updatedAt = now, ) ) @@ -148,6 +189,10 @@ class DocumentationRepository( labelSnapshot = item.labelSnapshot, itemOrder = item.itemOrder, checked = item.checked, + isFreeText = item.isFreeText, + isStaticHint = item.isStaticHint, + freeTextValue = item.freeTextValue, + inputSpec = item.inputSpec, ) } ) @@ -157,7 +202,11 @@ class DocumentationRepository( if (copyPhotos) { 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( PhotoEntity( id = UUID.randomUUID().toString(), @@ -174,27 +223,33 @@ class DocumentationRepository( } /** - * Komfort-Methode für die Patientenliste (Aufgabenstellung Abschnitt 6, Aktion - * "Behandlung/Dokumentation fortsetzen"): ermittelt die zuletzt erstellte Dokumentation - * eines Patienten und ruft darauf [continueDocumentation] auf. Liefert `null`, wenn der - * Patient noch keine Dokumentation besitzt (UI muss diesen Fall abfangen, s. Aufgabenstellung - * Abschnitt 20). + * Komfort-Methode für die Patientenliste ("Behandlung/Dokumentation fortsetzen"): ermittelt + * die zuletzt erstellte Dokumentation eines Patienten und ruft darauf + * [continueDocumentation] auf. Liefert `null`, wenn der Patient noch keine Dokumentation + * besitzt. */ suspend fun continueLatestForPatient(patientId: String): Documentation? { val latestId = documentationDao.getLatestForPatient(patientId)?.documentation?.id ?: return null return continueDocumentation(latestId) } - suspend fun updateFreeText(blockStateId: String, text: String) { - blockStateDao.updateFreeText(blockStateId, text, Instant.now().toEpochMilli()) - } - + /** Speichert den Zustand einer normalen Checkbox ODER der Freitext-Checkbox eines Blocks. */ suspend fun setChecked(checkItemStateId: String, checked: Boolean) { 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 { - val fileName = photoStorage.copyFrom(sourceUri) + val fileName = photoStorage.importFrom(documentationId, sourceUri) val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 val now = Instant.now() val entity = PhotoEntity( @@ -209,55 +264,164 @@ class DocumentationRepository( } /** - * Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Eine Room-Transaktion - * kann keine Dateisystemoperation einschließen. Reihenfolge: DB-Zeile zuerst, dann Datei – - * schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei zurück (unkritisch), - * statt eines DB-Eintrags, der auf eine nicht mehr existente Datei verweist (würde beim - * Anzeigen/PDF-Export zu einem sichtbaren Fehler führen). Siehe Architektur-Dokument - * Abschnitt 5.1. + * Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Reihenfolge DB-Zeile + * zuerst, dann Datei - schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei + * zurück (unkritisch), statt eines DB-Eintrags, der auf eine nicht mehr existente Datei + * verweist. */ suspend fun removePhoto(photoId: String) { val photo = photoDao.getById(photoId) ?: return photoDao.delete(photo) - runCatching { photoStorage.delete(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. + runCatching { photoStorage.delete(photo.documentationId, photo.fileName) } } - suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { - val fileName = signatureFileStorage.save(bitmap) + /** + * Erzeugt aus einer Wunddokumentation ein PDF im internen App-Speicher (PDF-001/002). Lädt + * die vollständige Dokumentation, übersetzt "nicht gefunden" in einen fachlichen Fehler + * statt einer NullPointerException, mappt anschließend auf das schlanke [de.medapp.wunddoku.pdf.PdfExportModel] + * (dabei werden über [DocumentationToPdfExportModelMapper] ausschließlich bearbeitete + * Blöcke gemäß der bestehenden BlockEditedPolicy sowie die historischen Snapshot-Werte + * verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter]. + */ + suspend fun exportPdf(documentationId: String): PdfExportResult { + val documentation = getFullDocumentation(documentationId) + ?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound) + val model = DocumentationToPdfExportModelMapper.map(documentation) + return pdfExporter.export(model) + } + + /** + * 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 entity = SignatureEntity( id = UUID.randomUUID().toString(), documentationId = documentationId, - signerName = signerName, + signerName = trimmedName, fileName = fileName, + orderIndex = nextOrderIndex, createdAt = now.toEpochMilli(), ) 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) { val signature = signatureDao.getById(signatureId) ?: return signatureDao.delete(signature) - runCatching { signatureFileStorage.delete(signature.fileName) } + runCatching { signatureStorage.delete(signature.documentationId, signature.fileName) } } suspend fun delete(documentationId: String) { 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) - photos.forEach { runCatching { photoStorage.delete(it.fileName) } } - signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } + // Je ein einziger Verzeichnis-Löschvorgang statt Einzeldateien für Fotos UND Signaturen + // - 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) { for (blockDef in DocumentationCatalog.blocks) { val blockStateId = UUID.randomUUID().toString() @@ -269,23 +433,44 @@ class DocumentationRepository( blockDefinitionId = blockDef.id, titleSnapshot = blockDef.title, blockOrder = blockDef.order, - freeText = "", updatedAt = now, ) ) ) - checkItemStateDao.insertAll( - blockDef.items.map { itemDef -> - CheckItemStateEntity( - id = UUID.randomUUID().toString(), - blockStateId = blockStateId, - checkItemDefinitionId = itemDef.id, - labelSnapshot = itemDef.label, - itemOrder = itemDef.order, - checked = false, - ) - } + + val catalogItems = blockDef.items.map { itemDef -> + CheckItemStateEntity( + id = UUID.randomUUID().toString(), + blockStateId = blockStateId, + checkItemDefinitionId = itemDef.id, + labelSnapshot = itemDef.label, + itemOrder = itemDef.order, + // 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) } } } diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt index 9574b13..b3484fa 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt @@ -67,7 +67,9 @@ class PatientRepository( id = patient.id, fullName = "${patient.firstName} ${patient.lastName}", birthDate = LocalDate.parse(patient.birthDate), - address = patient.address, + street = patient.street, + postalCode = patient.postalCode, + city = patient.city, lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) }, ) } diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt new file mode 100644 index 0000000..dd619b2 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt @@ -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 { + 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), + ) +} diff --git a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt index 315d3a8..4909755 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -4,10 +4,15 @@ import android.content.Context import de.medapp.wunddoku.data.database.AppDatabase import de.medapp.wunddoku.data.repository.DocumentationRepository 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.PhotoStorage -import de.medapp.wunddoku.signature.InternalSignatureFileStorage -import de.medapp.wunddoku.signature.SignatureFileStorage +import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter +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, @@ -26,12 +31,28 @@ class AppContainer(context: Context) { private val database: AppDatabase by lazy { AppDatabase.getInstance(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 { PatientRepository(patientDao = database.patientDao()) } + val woundCarerRepository: WoundCarerRepository by lazy { + WoundCarerRepository( + woundCarerDao = database.woundCarerDao(), + woundCarerSignatureStorage = woundCarerSignatureStorage, + ) + } + val documentationRepository: DocumentationRepository by lazy { DocumentationRepository( documentationDao = database.documentationDao(), @@ -39,8 +60,12 @@ class AppContainer(context: Context) { checkItemStateDao = database.checkItemStateDao(), photoDao = database.photoDao(), signatureDao = database.signatureDao(), + patientDao = database.patientDao(), photoStorage = photoStorage, - signatureFileStorage = signatureFileStorage, + signatureStorage = signatureStorage, + woundCarerRepository = woundCarerRepository, + pdfExporter = pdfExporter, + pdfToImagesConverter = pdfToImagesConverter, db = database, ) } diff --git a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt index 33fcc7a..0e163dc 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt @@ -1,58 +1,598 @@ 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. * - * WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter - * (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte"). - * Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser - * Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden. + * Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die + * handschriftlich vorgegebene 22-Punkte-Reihenfolge, zunächst ergänzt/verfeinert nach Abgleich + * mit dem ursprünglichen Papierformular, und in dieser Fassung ein weiteres Mal überarbeitet + * 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 * nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument * Abschnitt 2.3b. * * [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives - * Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke - * 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). + * Metadatum in Documentation.catalogVersion übernommen. Auf 4 erhöht mit diesem Umbau. */ 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 = (1..12).map { it.toString() } + private val WEEKDAY_COUNT_OPTIONS: List = (1..7).map { it.toString() } + private val MINUTE_1_TO_15_OPTIONS: List = (1..15).map { it.toString() } + private val DEPTH_HALF_CM_OPTIONS: List = 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 = listOf("I", "II", "III", "IV") val blocks: List = listOf( BlockDefinition( - id = "block_01", - title = "Block 1", + id = "wundart", + title = "Wundart", order = 1, items = listOf( - CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1), - CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2), + CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1), + 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( - id = "block_02", - title = "Block 2", + id = "lokalisation", + title = "Lokalisation", order = 2, items = listOf( - CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1), - CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2), - CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3), + CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1), ), ), BlockDefinition( - id = "block_03", - title = "Block 3", + id = "groesse", + title = "Größe", order = 3, items = listOf( - CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1), - CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2), + CheckItemDefinition(id = "groesse_hinweis_masse", label = "Maße", order = 1, isStaticHint = true), + 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 - // Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch. + BlockDefinition( + 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? = @@ -64,10 +604,47 @@ data class BlockDefinition( val title: String, val order: Int, val items: List, -) +) { + /** + * 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( val id: String, val label: String, val order: Int, + val isFreeText: Boolean = false, + val isStaticHint: Boolean = false, + val inputSpec: InputSpec? = null, ) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt index 713cf9a..98ca776 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt @@ -7,7 +7,24 @@ package de.medapp.wunddoku.domain.model * (CheckItemState.labelSnapshot in der Datenbank), NICHT aus einer Laufzeit-Auflösung des * aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Das stellt sicher, dass * 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( val checkItemStateId: String, @@ -15,4 +32,8 @@ data class CheckItem( val label: String, val order: Int, val checked: Boolean, + val isFreeText: Boolean = false, + val freeTextValue: String = "", + val isStaticHint: Boolean = false, + val inputSpec: String = "", ) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt index 0c09d97..215101d 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt @@ -16,6 +16,7 @@ import java.time.Instant data class Documentation( val id: String, val patientId: String, + val patientSnapshot: PatientStammdatenSnapshot, val status: DocumentationStatus, val createdAt: Instant, val updatedAt: Instant, diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt index 922f43d..63a2b0c 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt @@ -7,12 +7,12 @@ import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy * Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht * 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 - * Eigenschaft berechnet ("computed property", kein eigenes Feld im Konstruktor). Das - * verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen Inhalt abweichen kann. - * [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. + * Eigenschaft berechnet. Das verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen + * Inhalt abweichen kann. [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert. */ data class DocumentationBlock( val blockStateId: String, @@ -20,7 +20,6 @@ data class DocumentationBlock( val title: String, val order: Int, val items: List, - val freeText: String, ) { val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this) } diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/InputSpec.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/InputSpec.kt new file mode 100644 index 0000000..c34277b --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/InputSpec.kt @@ -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, + ) : 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, +) { + + /** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */ + fun decodeValues(stored: String): List { + 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 = 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) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt index f71d0c6..824b8fb 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt @@ -9,16 +9,20 @@ import java.time.LocalDate * * 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, - * 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. + * Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen. + * + * 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( val id: String, val firstName: String, val lastName: String, val birthDate: LocalDate, - val address: String, + val street: String, + val postalCode: String, + val city: String, val phone1: String, val phone2: String?, val phone3: String?, @@ -26,4 +30,5 @@ data class Patient( val updatedAt: Instant, ) { val fullName: String get() = "$firstName $lastName" + val displayAddress: String get() = "$street, $postalCode $city" } diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt index 9ff7370..2e75d87 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt @@ -4,14 +4,18 @@ import java.time.Instant import java.time.LocalDate /** - * Schlanke Projektion für die Patientenliste (Architektur-Dokument Abschnitt 6), inklusive - * Datum der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die + * Schlanke Projektion für die Patientenliste (Aufgabenstellung Abschnitt 6), inklusive Datum + * der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die * Listen-Query nicht implizit an eine bestimmte Detaildarstellung gekoppelt ist. */ data class PatientListItem( val id: String, val fullName: String, val birthDate: LocalDate, - val address: String, + val street: String, + val postalCode: String, + val city: String, val lastDocumentationAt: Instant?, -) +) { + val displayAddress: String get() = "$street, $postalCode $city" +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientStammdatenSnapshot.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientStammdatenSnapshot.kt new file mode 100644 index 0000000..a8e397e --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientStammdatenSnapshot.kt @@ -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" +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt index b825574..8ffbcb1 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt @@ -5,15 +5,18 @@ import java.time.Instant /** * Eine digitale Unterschrift zu einer Wunddokumentation. * - * Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später - * mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können - * (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen - * App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11. + * Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit beliebig + * viele Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können + * (1:n-Beziehung, MEDIA-004). [fileName] verweist auf eine PNG-Datei im internen App-Speicher + * (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( val id: String, val documentationId: String, - val signerName: String?, + val signerName: String, val fileName: String, + val orderIndex: Int, val createdAt: Instant, ) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt new file mode 100644 index 0000000..b582680 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt @@ -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, +) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt index eb0ba07..339d7ef 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt @@ -15,9 +15,23 @@ import de.medapp.wunddoku.domain.model.DocumentationBlock */ 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 { - val hasFreeText = block.freeText.isNotBlank() - val hasCheckedItem = block.items.any { it.checked } - return hasFreeText || hasCheckedItem + return block.items.any { it.checked && !it.isStaticHint } } } diff --git a/app/src/main/java/de/medapp/wunddoku/media/ImageDownsampler.kt b/app/src/main/java/de/medapp/wunddoku/media/ImageDownsampler.kt new file mode 100644 index 0000000..1d0c53a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/media/ImageDownsampler.kt @@ -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) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt index 961a38e..e6fb579 100644 --- a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt @@ -1,65 +1,292 @@ package de.medapp.wunddoku.media import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File 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 - * EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der - * Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten, - * dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern. + * ``` + * filesDir/ + * └── photos/ + * └── / + * ├── .jpg + * └── ... + * ``` * - * Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung - * einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21). + * Ein eigenes Unterverzeichnis pro Dokumentation (statt eines einzigen flachen Ordners für alle + * 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 { - /** 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 - * deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue - * DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das - * Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen. + * Importiert ein vom Android Photo Picker ausgewähltes Bild: liest es speicherschonend + * (Downsampling auf [ImageDownsampler.MAX_STORED_DIMENSION_PX] längster Kante über + * `BitmapFactory.Options.inSampleSize`, ohne das Originalbild vollständig in den Speicher + * 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). */ - fun resolve(fileName: String): File + /** Löscht eine einzelne physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */ + 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 { - private val photosDir: File by lazy { + private val photosRootDir: File by lazy { File(context.filesDir, "photos").apply { mkdirs() } } - override suspend fun copyFrom(sourceUri: Uri): String { - val fileName = "${UUID.randomUUID()}.jpg" - val targetFile = File(photosDir, fileName) - context.contentResolver.openInputStream(sourceUri)?.use { input -> - targetFile.outputStream().use { output -> input.copyTo(output) } - } ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri") - return fileName + private fun documentationDirPath(documentationId: String): File = + File(photosRootDir, documentationId) + + private fun ensureDocumentationDir(documentationId: String): File = + documentationDirPath(documentationId).apply { mkdirs() } + + override fun resolve(documentationId: String, fileName: String): File = + File(documentationDirPath(documentationId), fileName) + + override suspend fun importFrom(documentationId: String, sourceUri: Uri): String = + withContext(Dispatchers.IO) { + val resolver = context.contentResolver + + // 1) Nur die Abmessungen ermitteln (inJustDecodeBounds), ohne Pixel zu laden. + // + // WICHTIG: BitmapFactory.decodeStream(...) liefert bei inJustDecodeBounds = true + // IMMER null zurück (es befüllt nur boundsOptions, gibt aber nie ein Bitmap zurück). + // Die Null-Prüfung muss sich deshalb auf den STREAM selbst beziehen, nicht auf das + // (stets null-wertige) Ergebnis von decodeStream() - sonst würde jeder Import- + // Versuch fälschlich als "Bild konnte nicht geöffnet werden" fehlschlagen, komplett + // unabhängig vom tatsächlichen Bild. + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + val boundsStream = resolver.openInputStream(sourceUri) + ?: error("Bild konnte nicht geöffnet werden (Uri nicht mehr gültig?).") + boundsStream.use { input -> BitmapFactory.decodeStream(input, null, boundsOptions) } + + if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) { + error("Bildabmessungen konnten nicht ermittelt werden - vermutlich kein unterstütztes Bildformat.") + } + + val sampleSize = ImageDownsampler.computeInSampleSize( + boundsOptions.outWidth, + boundsOptions.outHeight, + ImageDownsampler.MAX_STORED_DIMENSION_PX, + ) + + // 2) Tatsächlich decodieren - bereits mit inSampleSize speicherschonend heruntergerechnet. + // Ein content://-InputStream lässt sich i. d. R. nicht zurückspulen, daher wird die + // Quelle für jeden Schritt separat neu geöffnet. + val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } + val decoded = resolver.openInputStream(sourceUri)?.use { input -> + BitmapFactory.decodeStream(input, null, decodeOptions) + } ?: error("Bild konnte nicht dekodiert werden.") + + // 3) EXIF-Ausrichtung auslesen. Fehlende/nicht lesbare EXIF-Daten führen bewusst zu + // ORIENTATION_NORMAL (keine Korrektur) statt zu einem Fehler - ein Bild ohne EXIF + // ist kein Importfehler. + val orientation = resolver.openInputStream(sourceUri)?.use { input -> + runCatching { + ExifInterface(input).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + } ?: ExifInterface.ORIENTATION_NORMAL + + val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation) + + // 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis + // speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen + // EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu + // übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener + // Nebeneffekt, keine bewusste Anforderung dieser Aufgabe. + val targetDir = ensureDocumentationDir(documentationId) + val fileName = "${UUID.randomUUID()}.jpg" + 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 { - val newFileName = "${UUID.randomUUID()}.jpg" - resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false) - return newFileName + override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) { + resolve(documentationId, fileName).delete() + Unit } - override suspend fun delete(fileName: String) { - resolve(fileName).delete() + override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) { + 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() + } } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt new file mode 100644 index 0000000..8ec41bf --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -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//.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() + // 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() + 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, + ) { + 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, + ) { + 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, + ) { + 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) + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt new file mode 100644 index 0000000..7f011c6 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt @@ -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 +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt new file mode 100644 index 0000000..269e5dd --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt new file mode 100644 index 0000000..94d0ae8 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt @@ -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, + /** Bereits sortiert nach orderIndex (PDF-003: Unterschriften). Reihenfolge im fertigen PDF: + * Dokumentationsblöcke -> Signaturen -> Fotoanhang. */ + val signatures: List, + /** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */ + val photos: List, +) + +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, +) + +/** 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, +) diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfFileNameGenerator.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfFileNameGenerator.kt new file mode 100644 index 0000000..e17cf60 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfFileNameGenerator.kt @@ -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") +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfImageSharer.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfImageSharer.kt new file mode 100644 index 0000000..6f9dc84 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfImageSharer.kt @@ -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): 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): 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.") + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt new file mode 100644 index 0000000..ba4a9f5 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt @@ -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 -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.") + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt new file mode 100644 index 0000000..12b126a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt @@ -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() + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfSharer.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfSharer.kt new file mode 100644 index 0000000..6ecdb5a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfSharer.kt @@ -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.") + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfToImagesConverter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfToImagesConverter.kt new file mode 100644 index 0000000..422a249 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfToImagesConverter.kt @@ -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) : 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() + 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 + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt new file mode 100644 index 0000000..dc9066a --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt @@ -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 } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt new file mode 100644 index 0000000..29b205f --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt @@ -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/ + * └── / + * └── .png + * ``` + * + * Ersetzt den ursprünglichen Platzhalter `SignatureFileStorage`, der noch flach unter + * `filesDir/signatures/.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///`), + * 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() + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/components/format/FormattedInput.kt b/app/src/main/java/de/medapp/wunddoku/ui/components/format/FormattedInput.kt new file mode 100644 index 0000000..0e58985 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/components/format/FormattedInput.kt @@ -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, + 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) diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index ee370a7..073c005 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt @@ -1,44 +1,110 @@ 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.Box +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.size +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.Checkbox import androidx.compose.material3.CircularProgressIndicator 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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField 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.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +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.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.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import de.medapp.wunddoku.R 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.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, - * Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und - * Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen - * Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5. + * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit + * Vor-/Zurück-Buttons und Positionsanzeige ("7 / 20"), darunter dokumentationsweit (nicht + * blockbezogen) den Fotobereich (MEDIA-001/002). Die von der Aufgabenstellung bevorzugte + * 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) @Composable @@ -48,10 +114,16 @@ fun DocumentationEditScreen( ) { 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(null) } + // Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren - // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument - // Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche - // Absicherung für den häufigen Fall. + // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem + // Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -63,15 +135,82 @@ fun DocumentationEditScreen( 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 -> 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( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { if (uiState.blockCount > 0) { Text( stringResource( - R.string.documentation_edit_position, - uiState.currentBlockIndex + 1, + R.string.documentation_edit_progress, + uiState.editedBlockCount, uiState.blockCount, ) ) @@ -80,9 +219,27 @@ fun DocumentationEditScreen( } }, actions = { - // PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die - // Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave - // gesichert, kein expliziter Speichern-Button nötig). + // PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button + // erzeugt die Datei im internen Speicher (PDF-001/002). + 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") } }, ) @@ -101,68 +258,542 @@ fun DocumentationEditScreen( else -> { val documentation = uiState.documentation - val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) - if (documentation == null || currentBlock == null) { + if (documentation == null || documentation.blocks.isEmpty()) { Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Text("Diese Dokumentation enthält keine Blöcke.") } } else { - DocumentationBlockContent( + DocumentationEditContent( 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, - onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) }, - canGoBack = uiState.currentBlockIndex > 0, - canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1, - onPrevious = viewModel::onPreviousBlock, - onNext = viewModel::onNextBlock, + onFreeTextChanged = viewModel::onFreeTextChanged, + photoImportInProgress = uiState.photoImportInProgress, + photoImportError = uiState.photoImportError, + onDismissPhotoImportError = viewModel::dismissPhotoImportError, + 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 -private fun DocumentationBlockContent( +private fun DocumentationEditContent( modifier: Modifier = Modifier, - block: DocumentationBlock, + blocks: List, + expandedBlockId: String?, + onToggleBlockExpanded: (blockStateId: String) -> Unit, + photos: List, + signatures: List, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, - onFreeTextChanged: (String) -> Unit, - canGoBack: Boolean, - canGoNext: Boolean, - onPrevious: () -> Unit, - onNext: () -> Unit, + onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, + photoImportInProgress: Boolean, + photoImportError: String?, + onDismissPhotoImportError: () -> Unit, + onAddPhotosClick: () -> Unit, + onDeletePhotoRequested: (photoId: String) -> Unit, + loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, + onPhotoClick: (Photo) -> Unit, + onAddSignatureClick: () -> Unit, + onDeleteSignatureRequested: (signatureId: String) -> Unit, + loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, ) { - Column( - modifier = modifier - .verticalScroll(rememberScrollState()) - .padding(16.dp), + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(16.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) } - - OutlinedTextField( - value = block.freeText, - onValueChange = onFreeTextChanged, - label = { Text(stringResource(R.string.documentation_edit_free_text_label)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - ) - - Row( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - OutlinedButton(onClick = onPrevious, enabled = canGoBack) { - Text(stringResource(R.string.documentation_edit_previous)) - } - Button(onClick = onNext, enabled = canGoNext) { - Text(stringResource(R.string.documentation_edit_next)) + // MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt + // darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im + // späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003). + item(key = "signatures_header") { + Column { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.documentation_edit_signatures_title), + style = MaterialTheme.typography.titleMedium, + ) + OutlinedButton(onClick = onAddSignatureClick) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.documentation_edit_add_signature)) + } + } } } + + if (signatures.isEmpty()) { + item(key = "signatures_empty") { + Text( + stringResource(R.string.documentation_edit_no_signatures), + style = MaterialTheme.typography.bodySmall, + ) + } + } else { + items(signatures, key = { it.id }) { signature -> + SignatureRow( + signature = signature, + loadBitmap = loadSignatureBitmap, + onDeleteRequested = { onDeleteSignatureRequested(signature.id) }, + ) + } + } + + item(key = "photos_header") { + Column { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(stringResource(R.string.documentation_edit_photos_title), style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (photoImportInProgress) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.documentation_edit_add_photos)) + } + } + } + } + } + + if (photoImportError != null) { + item(key = "photo_import_error") { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + photoImportError, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismissPhotoImportError) { Text("OK") } + } + } + } + + if (photos.isEmpty()) { + item(key = "photos_empty") { + Text( + stringResource(R.string.documentation_edit_no_photos), + style = MaterialTheme.typography.bodySmall, + ) + } + } else { + items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row -> + PhotoRow( + photos = row, + loadThumbnail = loadThumbnail, + onDeleteRequested = onDeletePhotoRequested, + onPhotoClick = onPhotoClick, + ) + } + } + } +} + +private const val PHOTO_GRID_COLUMNS = 3 + +@Composable +private fun PhotoRow( + photos: List, + 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(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(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) } } + +/** + * 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 + }, + ) + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index f62e5be..91bf8ca 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -1,5 +1,7 @@ package de.medapp.wunddoku.ui.documentation.edit +import android.graphics.Bitmap +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope @@ -7,8 +9,14 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import androidx.navigation.toRoute import de.medapp.wunddoku.data.repository.DocumentationRepository +import de.medapp.wunddoku.data.repository.WoundCarerRepository import de.medapp.wunddoku.di.wunddokuApplication 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 kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow @@ -18,14 +26,76 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.io.File data class DocumentationEditUiState( 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 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? = 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 + + /** 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( private val documentationId: String, private val repository: DocumentationRepository, + private val woundCarerRepository: WoundCarerRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(DocumentationEditUiState()) @@ -51,7 +122,8 @@ class DocumentationEditViewModel( private val freeTextChanges = MutableSharedFlow>(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() init { @@ -74,29 +146,37 @@ class DocumentationEditViewModel( @OptIn(FlowPreview::class) private fun observeFreeTextChanges() { viewModelScope.launch { - freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) -> - persistFreeText(blockStateId, text) + freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (checkItemStateId, text) -> + persistFreeText(checkItemStateId, text) } } } - private suspend fun persistFreeText(blockStateId: String, text: String) { - repository.updateFreeText(blockStateId, text) + private suspend fun persistFreeText(checkItemStateId: String, text: String) { + repository.updateFreeText(checkItemStateId, text) // 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) - pendingFreeText[blockStateId] = text - freeTextChanges.tryEmit(blockStateId to text) + /** + * [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText = + * true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine + * 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 -> val documentation = state.documentation ?: return@update state 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)) } @@ -118,20 +198,345 @@ class DocumentationEditViewModel( 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 -> - val lastIndex = state.blockCount - 1 - if (lastIndex < 0) return@update state - state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex)) + val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId + state.copy(expandedBlockId = newExpandedId) } } - fun onPreviousBlock() { - _uiState.update { state -> - state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0)) + /** + * Importiert die vom Android Photo Picker ausgewählten Bilder (MEDIA-001). Ein leerer + * [uris] (Nutzer hat den Picker ohne Auswahl verlassen) ist kein Fehler und wird + * stillschweigend ignoriert. + * + * Jedes Bild wird EINZELN abgefangen: Schlägt der Import eines Bildes fehl (defektes + * Format, nicht mehr lesbare Uri o. Ä.), werden die übrigen, erfolgreich importierbaren + * Bilder trotzdem übernommen - ein einzelnes fehlerhaftes Bild darf einen Mehrfachimport + * nicht komplett verhindern. Am Ende wird die Dokumentation einmalig neu geladen, damit die + * UI konsistent den tatsächlichen Datenbankstand zeigt. + */ + fun onPhotosPicked(uris: List) { + 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 = 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 * Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei @@ -142,7 +547,7 @@ class DocumentationEditViewModel( val toFlush = pendingFreeText.toMap() pendingFreeText.clear() 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( documentationId = route.documentationId, repository = container.documentationRepository, + woundCarerRepository = container.woundCarerRepository, ) } } diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/PhotoViewerDialog.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/PhotoViewerDialog.kt new file mode 100644 index 0000000..3f66fc5 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/PhotoViewerDialog.kt @@ -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(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) + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt new file mode 100644 index 0000000..4f6e290 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt @@ -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, + loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?, +) { + var signerName by remember { mutableStateOf("") } + var strokes by remember { mutableStateOf>(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(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(null) } + var suggestions by remember { mutableStateOf>(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)) + } + } + } + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt new file mode 100644 index 0000000..6f4f30e --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt @@ -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 + +/** + * 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, + onStrokesChanged: (List) -> Unit, + modifier: Modifier = Modifier, +) { + var currentStroke by remember { mutableStateOf(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, 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 +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt index 727711a..69f3d74 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt @@ -1,20 +1,27 @@ package de.medapp.wunddoku.ui.patients.form +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator 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.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -31,15 +38,23 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel 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 - * das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über - * [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm]. + * Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von + * Patienten-Stammdaten – unterschieden über [PatientFormViewModel.startsNewDocumentation]. * - * Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT) - * erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle - * Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst. + * Geburtsdatum und Telefonnummern nutzen dieselbe wiederverwendbare + * [DigitGroupVisualTransformation] (ui/components/format/FormattedInput.kt): Der zugrunde + * 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) @Composable @@ -95,6 +110,10 @@ fun PatientFormScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (!uiState.isEditing) { + ExistingPatientSearchSection(uiState = uiState, viewModel = viewModel) + } + OutlinedTextField( value = uiState.firstName, onValueChange = viewModel::onFirstNameChanged, @@ -108,42 +127,62 @@ fun PatientFormScreen( modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( - value = uiState.birthDateText, - onValueChange = viewModel::onBirthDateTextChanged, + value = uiState.birthDateDigits, + onValueChange = viewModel::onBirthDateDigitsChanged, 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(), ) OutlinedTextField( - value = uiState.address, - onValueChange = viewModel::onAddressChanged, - label = { Text(stringResource(R.string.patient_form_address)) }, + value = uiState.postalCode, + onValueChange = viewModel::onPostalCodeChanged, + label = { Text(stringResource(R.string.patient_form_postal_code)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = uiState.city, + onValueChange = viewModel::onCityChanged, + label = { Text(stringResource(R.string.patient_form_city)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( value = uiState.phone1, onValueChange = viewModel::onPhone1Changed, 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(), ) OutlinedTextField( value = uiState.phone2, onValueChange = viewModel::onPhone2Changed, 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(), ) OutlinedTextField( value = uiState.phone3, onValueChange = viewModel::onPhone3Changed, 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(), ) 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()) { @@ -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, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt index 8f27c1d..d8e3d4f 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt @@ -10,29 +10,55 @@ import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.di.wunddokuApplication 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.Instant -import java.time.LocalDate data class PatientFormUiState( val isEditing: Boolean = false, val isLoading: Boolean = true, val firstName: String = "", val lastName: String = "", - /** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */ - val birthDateText: String = "", - val address: String = "", + /** Rohe Ziffernfolge (max. 8 Zeichen, TTMMJJJJ) - die Formatierung TT.MM.JJJJ erfolgt + * ausschließlich über eine VisualTransformation in der Compose-UI, s. + * 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 phone2: String = "", val phone3: String = "", val validationError: String? = null, /** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */ val duplicateWarning: Patient? = null, + /** Suchfeld "bestehenden Patienten auswählen" - nur relevant, solange isEditing = false. */ + val patientSearchQuery: String = "", + val patientSearchResults: List = 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. */ val patientSaved: Boolean = false, /** != 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 - * (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6). + * Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab. */ class PatientFormViewModel( private val patientId: String?, @@ -53,7 +78,12 @@ class PatientFormViewModel( private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null)) val uiState: StateFlow = _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 { + observePatientSearch() if (patientId != null) { viewModelScope.launch { val patient = repository.getById(patientId) @@ -63,8 +93,10 @@ class PatientFormViewModel( isLoading = false, firstName = patient.firstName, lastName = patient.lastName, - birthDateText = patient.birthDate.toString(), - address = patient.address, + birthDateDigits = patient.birthDate.toGermanDateDigits(), + street = patient.street, + postalCode = patient.postalCode, + city = patient.city, phone1 = patient.phone1, phone2 = patient.phone2.orEmpty(), phone3 = patient.phone3.orEmpty(), @@ -81,11 +113,73 @@ class PatientFormViewModel( fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = 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 onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) } - fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) } - fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) } + + fun onBirthDateDigitsChanged(raw: String) = + _uiState.update { it.copy(birthDateDigits = filterDigits(raw, DATE_MAX_DIGITS)) } + + 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) } @@ -105,23 +199,27 @@ class PatientFormViewModel( fun save(skipDuplicateCheck: Boolean = false) { val state = _uiState.value - val birthDate = try { - LocalDate.parse(state.birthDateText.trim()) - } catch (e: Exception) { - _uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") } + val birthDate = germanDateDigitsToLocalDateOrNull(state.birthDateDigits) + if (birthDate == null) { + _uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum eingeben (TT.MM.JJJJ).") } return } if (state.firstName.isBlank() || state.lastName.isBlank()) { _uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") } return } - if (state.address.isBlank() || state.phone1.isBlank()) { - _uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") } + if (state.street.isBlank() || state.postalCode.isBlank() || state.city.isBlank()) { + _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 } 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) if (existing != null) { _uiState.update { it.copy(duplicateWarning = existing, validationError = null) } @@ -130,14 +228,16 @@ class PatientFormViewModel( } val patient = Patient( - id = patientId ?: repository.newId(), + id = patientId ?: state.selectedExistingPatientId ?: repository.newId(), firstName = state.firstName.trim(), lastName = state.lastName.trim(), birthDate = birthDate, - address = state.address.trim(), - phone1 = state.phone1.trim(), - phone2 = state.phone2.trim().ifBlank { null }, - phone3 = state.phone3.trim().ifBlank { null }, + street = state.street.trim(), + postalCode = state.postalCode.trim(), + city = state.city.trim(), + phone1 = state.phone1, + phone2 = state.phone2.ifBlank { null }, + phone3 = state.phone3.ifBlank { null }, createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt updatedAt = Instant.EPOCH, ) @@ -150,8 +250,8 @@ class PatientFormViewModel( /** * Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu * gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine - * neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder - * nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste). + * neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder nur + * signalisieren, dass gespeichert wurde (zurück zur Patientenliste). */ private suspend fun finish(resolvedPatientId: String) { if (startsNewDocumentation) { @@ -166,10 +266,6 @@ class PatientFormViewModel( fun factory() = viewModelFactory { initializer { 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 route = savedStateHandle.toRoute() PatientFormViewModel( diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt index 65617f9..9849378 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt @@ -163,7 +163,7 @@ private fun PatientListItemCard( "Geburtsdatum: ${item.birthDate.format(dateFormatter)}", style = MaterialTheme.typography.bodySmall, ) - Text(item.address, style = MaterialTheme.typography.bodySmall) + Text(item.displayAddress, style = MaterialTheme.typography.bodySmall) val lastDoc = item.lastDocumentationAt if (lastDoc != null) { Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 106d7de..569d7a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,17 +24,38 @@ Vorname Nachname Geburtsdatum - Adresse + Straße & Hausnummer + PLZ + Stadt Telefonnummer 1 Telefonnummer 2 (optional) Telefonnummer 3 (optional) Speichern - %1$d / %2$d - Zurück - Weiter + %1$d / %2$d bearbeitet + Block bearbeitet Freitext + Fotos + Hinzufügen + Noch keine Fotos vorhanden. + Foto konnte nicht geladen werden. + Foto löschen? + Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden. + + Unterschriften + Signatur hinzufügen + Noch keine Unterschriften vorhanden. + Unterschrift löschen? + Diese Unterschrift wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden. + + Signatur hinzufügen + Name des Wundversorgers + Zurücksetzen + Speichern + Neu zeichnen + Bitte in einem Zug unterschreiben, ohne abzusetzen - bei erneutem Ansetzen wird die Zeichnung zurückgesetzt. + Abbrechen Bestätigen diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index fd6169d..c5bfa87 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -7,6 +7,7 @@ --> + diff --git a/settings.gradle.kts b/settings.gradle.kts index 52fe9f4..5997342 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,9 +5,6 @@ pluginManagement { gradlePluginPortal() } } -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" -} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)