From c52229b7f7a3531a9aa020953f6f4f2561f70889 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Sun, 6 Sep 2026 21:19:58 +0200 Subject: [PATCH] PDF/MEDIA MEDIA-003 MEDIA-004 PDF-003 --- .../3.json | 585 ++++++++++++++++ .../4.json | 623 ++++++++++++++++++ .../medapp/wunddoku/data/dao/SignatureDao.kt | 9 +- .../medapp/wunddoku/data/dao/WoundCarerDao.kt | 33 + .../wunddoku/data/database/AppDatabase.kt | 35 +- .../wunddoku/data/database/Migrations.kt | 113 ++++ .../wunddoku/data/entity/SignatureEntity.kt | 16 +- .../wunddoku/data/entity/WoundCarerEntity.kt | 37 ++ .../data/mapper/DocumentationMapper.kt | 21 +- .../repository/DocumentationRepository.kt | 66 +- .../data/repository/WoundCarerRepository.kt | 82 +++ .../de/medapp/wunddoku/di/AppContainer.kt | 26 +- .../medapp/wunddoku/domain/model/Signature.kt | 13 +- .../wunddoku/domain/model/WoundCarer.kt | 16 + .../pdf/AndroidPdfDocumentExporter.kt | 137 ++++ .../DocumentationToPdfExportModelMapper.kt | 8 + .../de/medapp/wunddoku/pdf/PdfExportModel.kt | 15 + .../de/medapp/wunddoku/pdf/PdfPageCursor.kt | 70 ++ .../signature/SignatureBitmapValidation.kt | 29 + .../wunddoku/signature/SignatureStorage.kt | 132 ++++ .../edit/DocumentationEditScreen.kt | 171 +++++ .../edit/DocumentationEditViewModel.kt | 94 +++ .../edit/signature/AddSignatureDialog.kt | 235 +++++++ .../edit/signature/SignaturePad.kt | 156 +++++ app/src/main/res/values/strings.xml | 13 + 25 files changed, 2684 insertions(+), 51 deletions(-) create mode 100644 app/schemas/de.medapp.wunddoku.data.database.AppDatabase/3.json create mode 100644 app/schemas/de.medapp.wunddoku.data.database.AppDatabase/4.json create mode 100644 app/src/main/java/de/medapp/wunddoku/data/dao/WoundCarerDao.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/entity/WoundCarerEntity.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/data/repository/WoundCarerRepository.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/WoundCarer.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/signature/SignatureBitmapValidation.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/AddSignatureDialog.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/signature/SignaturePad.kt 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/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 a196638..5c1cec3 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,12 +11,14 @@ 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 /** @@ -26,18 +28,22 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter * 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. + * DocumentationEntity. Dieser Sprung nutzte noch `fallbackToDestructiveMigration()` (die App + * befand sich vor dem produktiven Einsatz, keine echten Patientendaten zu migrieren). * - * `fallbackToDestructiveMigration()`: Da die App sich noch vor dem produktiven Einsatz befindet - * und keine echten Patientendaten migriert werden müssen, wird hier bewusst auf eine - * geschriebene `Migration`-Klasse verzichtet - die lokale Datenbank wird bei einem - * Versionssprung einfach neu angelegt (bestehende lokale Testdaten auf dem Gerät/Emulator gehen - * dabei verloren). Das ist eine bewusste, vorübergehende Entwicklungsentscheidung: VOR dem - * ersten produktiven Rollout muss dies durch eine echte `Migration(1, 2) { ... }` ersetzt - * werden, die reale Patientendaten erhält (insbesondere: die bisherige `address`-Spalte müsste - * dann sinnvoll in street/postalCode/city überführt werden, z. B. gesamten Altwert nach - * `street` und postalCode/city leer lassen, da eine automatische Text-Zerlegung einer - * Freitext-Adresse nicht zuverlässig möglich ist). + * 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. */ @Database( entities = [ @@ -47,8 +53,9 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter CheckItemStateEntity::class, PhotoEntity::class, SignatureEntity::class, + WoundCarerEntity::class, ], - version = 2, + version = 4, exportSchema = true, ) @TypeConverters(DocumentationStatusConverter::class) @@ -60,6 +67,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" @@ -74,7 +82,8 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, DATABASE_NAME, ) - .fallbackToDestructiveMigration() // s. Klassenkommentar: vorübergehend, vor Produktivbetrieb ersetzen + // Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar. + .addMigrations(MIGRATION_2_3, MIGRATION_3_4) .build().also { instance = it } } } 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..31f5ec1 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/data/database/Migrations.kt @@ -0,0 +1,113 @@ +package de.medapp.wunddoku.data.database + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Migration von Schema-Version 2 auf 3 (MEDIA-004/PDF-003: Mehrere Signaturen pro + * Dokumentation). + * + * Zwei Änderungen am `signatures`-Schema, die NICHT über ein einfaches `ALTER TABLE ... ADD + * COLUMN` abgebildet werden können: + * + * 1. Neue Spalte `orderIndex INTEGER NOT NULL` für eine stabile Reihenfolge mehrerer + * Signaturen (bisher gab es nur eine implizite, undefinierte Reihenfolge). + * 2. `signerName` wechselt von NULLABLE auf NOT NULL. SQLite kann eine bestehende Spalte nicht + * nachträglich auf NOT NULL umstellen - deshalb wird die Tabelle komplett neu aufgebaut + * (neue Tabelle mit Zielschema anlegen, Daten verlustfrei übernehmen, alte Tabelle + * entfernen, neue Tabelle umbenennen, Index wiederherstellen). + * + * Es wird NICHT angenommen, dass die Tabelle leer ist, nur weil die Signaturfunktion vor + * dieser Version über keine UI erreichbar war (Aufgabenstellung Abschnitt 22) - die Migration + * behandelt den allgemeinen Fall mit ggf. bereits vorhandenen Zeilen: + * + * - `orderIndex` wird pro `documentationId` anhand von `createdAt` (mit `id` als + * Tie-Breaker für den unwahrscheinlichen Fall exakt gleicher Zeitstempel) rückwirkend + * vergeben, beginnend bei 0 - über eine korrelierte Unterabfrage, da SQLite in der von Room + * unterstützten Form kein `ROW_NUMBER()`-Fenster in einem `UPDATE` bereitstellt. + * - `signerName` wird für Altzeilen mit NULL **oder** leerem/nur-Leerzeichen-Wert auf den klar + * als historisch fehlend erkennbaren technischen Platzhalter `"(nicht erfasst)"` gesetzt + * (ausdrücklich freigegeben - NICHT `""`, NICHT `"Unbekannt"`, da Letzteres wie ein echter + * Name wirken könnte). Der leere-String-Fall wurde defensiv ergänzt: `signerName` war in + * Schema 2 zwar nullable, aber nie auf "nicht blank" geprüft - ein rein theoretisch über + * einen früheren Code-Pfad denkbarer Leerstring soll denselben Platzhalter erhalten wie + * NULL, damit hier keine Zeile mit einem leeren, aber technisch "vorhandenen" Namen entsteht. + * Diese Regel gilt ausschließlich für migrierte Altbestände - neu erfasste Signaturen + * erzwingen weiterhin serverseitig (Repository) ein nicht-leeres, getrimmtes Pflichtfeld. + * + * Da die Signaturfunktion vor dieser Version über keine UI erreichbar war, ist in der Praxis auf + * so gut wie jeder realen Installation ohnehin mit einer leeren `signatures`-Tabelle zu rechnen + * - die obige Logik greift dann einfach nicht (INSERT ... SELECT liefert 0 Zeilen). + */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `signatures_new` ( + `id` TEXT NOT NULL, + `documentationId` TEXT NOT NULL, + `signerName` TEXT NOT NULL, + `fileName` TEXT NOT NULL, + `orderIndex` INTEGER NOT NULL, + `createdAt` INTEGER NOT NULL, + PRIMARY KEY(`id`), + FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO `signatures_new` (`id`, `documentationId`, `signerName`, `fileName`, `orderIndex`, `createdAt`) + SELECT + s.id, + s.documentationId, + CASE + WHEN s.signerName IS NULL OR TRIM(s.signerName) = '' THEN '(nicht erfasst)' + ELSE s.signerName + END, + s.fileName, + ( + SELECT COUNT(*) + FROM signatures s2 + WHERE s2.documentationId = s.documentationId + AND ( + s2.createdAt < s.createdAt + OR (s2.createdAt = s.createdAt AND s2.id < s.id) + ) + ), + s.createdAt + FROM signatures s + """.trimIndent() + ) + + db.execSQL("DROP TABLE `signatures`") + db.execSQL("ALTER TABLE `signatures_new` RENAME TO `signatures`") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `signatures` (`documentationId`)") + } +} + +/** + * Migration von Schema-Version 3 auf 4 (Änderungswunsch: Wundversorger-Register für + * Namensvorschläge und Standard-Vorbelegung im Signatur-Dialog). + * + * Rein additiv: eine neue, komplett eigenständige Tabelle `wound_carers` ohne Fremdschlüssel- + * Bezug zu einer bestehenden Tabelle (unabhängig vom Lebenszyklus einzelner Dokumentationen/ + * Signaturen, s. [de.medapp.wunddoku.data.repository.WoundCarerRepository]). Keine bestehenden + * Tabellen/Daten werden hierbei verändert. + */ +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `wound_carers` ( + `id` TEXT NOT NULL, + `name` TEXT NOT NULL, + `signatureFileName` TEXT NOT NULL, + `updatedAt` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + } +} 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/mapper/DocumentationMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt index 520bc14..e3ef34e 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 @@ -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( 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 7f4446a..c12f160 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 @@ -29,7 +29,8 @@ 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.signature.SignatureFileStorage +import de.medapp.wunddoku.signature.SignatureBitmapValidation +import de.medapp.wunddoku.signature.SignatureStorage import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.time.Instant @@ -56,7 +57,8 @@ class DocumentationRepository( 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 db: AppDatabase, ) { @@ -290,37 +292,73 @@ class DocumentationRepository( suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) - suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { - val fileName = signatureFileStorage.save(bitmap) + /** + * Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates + * Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein + * genug für die direkte Anzeige. Liefert `null` bei fehlender/beschädigter Datei (kein + * Absturz, konsistent zu [loadPhotoThumbnail]). + */ + suspend fun loadSignatureBitmap(documentationId: String, fileName: String): Bitmap? = + signatureStorage.decode(documentationId, fileName) + + /** + * Speichert eine neue Signatur (MEDIA-003/004). [signerName] ist fachliches Pflichtfeld: + * wird getrimmt, ein danach leerer String wird abgelehnt (Aufgabenstellung Abschnitt 8) - + * ebenso eine leere Zeichnung ([SignatureBitmapValidation.isBlank], Abschnitt 12: zweite, + * von der UI unabhängige Absicherungsebene). [orderIndex] wird analog [addPhoto] fortlaufend + * je Dokumentation vergeben, damit mehrere Signaturen eine stabile Reihenfolge haben + * (Anzeige UND spätere PDF-Reihenfolge, PDF-003). Die Namensvalidierung erfolgt VOR dem + * Schreiben der PNG-Datei, damit bei einem ungültigen Namen keine verwaiste Datei entsteht. + * + * Aktualisiert nach erfolgreichem Speichern zusätzlich das Wundversorger-Register + * ([WoundCarerRepository.recordSignatureUsage]) mit derselben Signatur - unabhängig davon, + * ob [bitmap] frisch gezeichnet oder aus einem Namensvorschlag im Dialog übernommen wurde. + * Das hat KEINEN Einfluss auf den hier gerade erzeugten, unveränderlichen [Signature]- + * Snapshot dieser Dokumentation. + */ + suspend fun saveSignature(documentationId: String, signerName: String, bitmap: Bitmap): Signature { + val trimmedName = signerName.trim() + require(trimmedName.isNotEmpty()) { "Der Name des Wundversorgers darf nicht leer sein." } + require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." } + + val fileName = signatureStorage.save(documentationId, bitmap) + val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1 val now = Instant.now() val 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 - val signatures = signatureDao.getForDocumentation(documentationId) documentationDao.delete(entity) - // Ein einziger Verzeichnis-Löschvorgang statt Einzeldateien - siehe PhotoStorage- - // Kommentar zur dokumentationsbezogenen Ordnerstruktur. Room-Cascade entfernt zwar die - // PhotoEntity-Zeilen automatisch, aber keine Dateien aus filesDir - das übernimmt - // ausschließlich diese Methode. + // 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) } - signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } + runCatching { signatureStorage.deleteAll(documentationId) } } /** 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 ab99822..ab5bbaf 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -4,12 +4,13 @@ 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.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter -import de.medapp.wunddoku.signature.InternalSignatureFileStorage -import de.medapp.wunddoku.signature.SignatureFileStorage +import de.medapp.wunddoku.signature.InternalSignatureStorage +import de.medapp.wunddoku.signature.SignatureStorage /** * Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories, @@ -28,13 +29,27 @@ class AppContainer(context: Context) { private val database: AppDatabase by lazy { AppDatabase.getInstance(context) } private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) } - private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) } - private val pdfExporter: DocumentationPdfExporter by lazy { AndroidPdfDocumentExporter(context, photoStorage) } + 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) + } 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(), @@ -44,7 +59,8 @@ class AppContainer(context: Context) { signatureDao = database.signatureDao(), patientDao = database.patientDao(), photoStorage = photoStorage, - signatureFileStorage = signatureFileStorage, + signatureStorage = signatureStorage, + woundCarerRepository = woundCarerRepository, pdfExporter = pdfExporter, db = database, ) 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/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt index 9812652..64f7b79 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -8,6 +8,7 @@ import android.graphics.Paint import android.graphics.pdf.PdfDocument import android.text.TextPaint import de.medapp.wunddoku.media.PhotoStorage +import de.medapp.wunddoku.signature.SignatureStorage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -47,6 +48,7 @@ import java.time.format.FormatStyle class AndroidPdfDocumentExporter( private val context: Context, private val photoStorage: PhotoStorage, + private val signatureStorage: SignatureStorage, ) : DocumentationPdfExporter { companion object { @@ -63,6 +65,25 @@ class AndroidPdfDocumentExporter( 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 + // Vektor-Checkbox: Kantenlänge des gezeichneten Kästchens und Abstand zum Label-Text. private const val CHECKBOX_SIZE = 10f private const val CHECKBOX_GAP = 6f @@ -116,6 +137,10 @@ class AndroidPdfDocumentExporter( // 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( @@ -132,6 +157,10 @@ class AndroidPdfDocumentExporter( 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) } @@ -159,6 +188,7 @@ class AndroidPdfDocumentExporter( // 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() } } } } @@ -197,6 +227,23 @@ class AndroidPdfDocumentExporter( color = Color.DKGRAY } + // PDF-003: Signaturbereich. + val signatureLabel = TextPaint().apply { + isAntiAlias = true + textSize = SIGNATURE_LABEL_TEXT_SIZE + color = Color.BLACK + } + val signatureNameUnderLine = TextPaint().apply { + isAntiAlias = true + textSize = SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE + color = Color.BLACK + } + val signaturePlaceholder = TextPaint().apply { + isAntiAlias = true + textSize = SIGNATURE_PLACEHOLDER_TEXT_SIZE + color = Color.DKGRAY + } + // Reine Vektor-Checkbox-Paints - keine Textschrift/Glyphen-Abhängigkeit. val checkboxOutline = Paint().apply { isAntiAlias = true @@ -301,6 +348,96 @@ class AndroidPdfDocumentExporter( } } + /** + * Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift + * "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil + * nach orderIndex sortiert, s. [PdfExportModel.signatures]). Anders als der Fotoanhang + * beginnt dieser Abschnitt NICHT zwingend auf einer neuen Seite - er schließt sich direkt an + * die zuletzt gezeichneten Dokumentationsblöcke an, sofern noch Platz vorhanden ist + * (Aufgabenstellung Abschnitt 25/28). + */ + private suspend fun drawSignatures( + cursor: PdfPageCursor, + model: PdfExportModel, + paints: ExportPaints, + retainedSignatureBitmaps: MutableList, + ) { + 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 diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt index 000ab2f..e9f02b6 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapper.kt @@ -56,6 +56,13 @@ object DocumentationToPdfExportModelMapper { .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, @@ -64,6 +71,7 @@ object DocumentationToPdfExportModelMapper { 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 index 8ee35ec..0a82e71 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfExportModel.kt @@ -19,6 +19,9 @@ data class PdfExportModel( 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, ) @@ -45,3 +48,15 @@ data class PdfExportItem( 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/PdfPageCursor.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt index 23257e2..12b126a 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfPageCursor.kt @@ -145,6 +145,76 @@ internal class PdfPageCursor( } } + /** + * Öffentliches Gegenstück zu [ensureSpace] für ATOMARE Blöcke, die aus mehreren + * Einzelzeichnungen bestehen (PDF-003: ein Signaturblock aus Wundversorgername, Bild, Linie + * und Namenszeile darf laut Aufgabenstellung Abschnitt 28 nicht zwischen zwei Seiten + * zerrissen werden). Der Aufrufer berechnet VOR dem eigentlichen Zeichnen die + * Gesamthöhe des Blocks (s. [measureTextHeight] für die enthaltenen Textanteile) und + * reserviert sie hier in einem Rutsch - reicht der verbleibende Platz nicht, beginnt eine + * komplett neue Seite, BEVOR auch nur die erste Teilzeichnung des Blocks beginnt. Anders als + * [drawText] (das einen zu langen Text selbst auf mehrere Seiten verteilen würde) gibt es + * hier keine interne Aufteilung - das ist hier gewünscht, kein Sonderfall. + */ + fun ensureBlockSpace(height: Float) { + ensureSpace(height) + } + + /** + * Berechnet die Gesamthöhe, die [text] beim Zeichnen mit [drawText] belegen würde, OHNE + * etwas zu zeichnen oder den Cursor zu verändern. Verwendet zur Berechnung dasselbe + * [buildLayout] wie [drawText] selbst (identischer Text/Paint/Breite), damit die hier + * gemessene und die später tatsächlich gezeichnete Höhe für ein und denselben Aufruf + * garantiert übereinstimmen - Voraussetzung dafür, dass eine vorab über [ensureBlockSpace] + * reservierte Blockhöhe beim tatsächlichen Zeichnen exakt ausreicht. + */ + fun measureTextHeight(text: String, paint: TextPaint, indent: Float = 0f): Float { + val width = (contentWidth - indent).toInt().coerceAtLeast(1) + return buildLayout(text, paint, width).height.toFloat() + } + + /** + * Zeichnet [bitmap] proportional in eine FESTE Zielbreite [targetWidth] eingepasst (Höhe + * ergibt sich aus dem Seitenverhältnis von [bitmap] - kein Zuschneiden, keine Verzerrung), + * linksbündig ab der aktuellen Cursor-Position. + * + * Bewusst ein GRUNDSÄTZLICH ANDERES Verhalten als [drawBitmapFitted] (PDF-004: füllt gezielt + * den GESAMTEN verbleibenden Platz der Seite - richtig für "genau ein Foto pro Seite"): Hier + * wird NICHT der restliche Seitenplatz ausgefüllt, sondern exakt die aus [targetWidth] + * abgeleitete Zielhöhe gezeichnet und der Cursor auch nur genau um diese Höhe + * weiterbewegt - Voraussetzung dafür, dass mehrere Signaturblöcke hintereinander auf + * derselben Seite stehen können, statt dass jeder einzelne Block wie ein Foto seine ganze + * Seite beansprucht (Aufgabenstellung Abschnitt 28: "Mehrere vollständige Signaturblöcke + * dürfen auf derselben Seite stehen, sofern genug Platz vorhanden ist"). + * + * Setzt voraus, dass für den vollständigen Block (inkl. dieses Bildes) bereits vorher + * [ensureBlockSpace] aufgerufen wurde - diese Methode selbst löst KEINEN Seitenumbruch aus. + */ + fun drawBitmapFixedWidth(bitmap: Bitmap, targetWidth: Float, paint: Paint, spacingAfter: Float = 0f): Float { + startIfNeeded() + if (bitmap.width <= 0 || bitmap.height <= 0) return 0f + val drawWidth = targetWidth.coerceAtMost(contentWidth.toFloat()) + val drawHeight = drawWidth * bitmap.height / bitmap.width + val left = marginLeft + val top = cursorY + canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint) + cursorY += drawHeight + spacingAfter + return drawHeight + } + + /** + * Kurze horizontale Linie fester Breite [width] (z. B. eine Unterschriftslinie), linksbündig + * ab der aktuellen Cursor-Position - im Gegensatz zu [drawDivider] NICHT über die volle + * Inhaltsbreite der Seite gezogen. Setzt wie [drawBitmapFixedWidth] ein vorheriges + * [ensureBlockSpace] für den gesamten umgebenden Block voraus. + */ + fun drawShortLine(width: Float, spacingBefore: Float = 0f, spacingAfter: Float = 0f) { + startIfNeeded() + cursorY += spacingBefore + canvas!!.drawLine(marginLeft, cursorY, marginLeft + width, cursorY, dividerPaint) + cursorY += spacingAfter + } + /** * Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert * über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach 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/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index 2253187..9acf19a 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 @@ -69,9 +69,11 @@ import de.medapp.wunddoku.R import de.medapp.wunddoku.domain.model.CheckItem 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.AndroidPdfOpener import de.medapp.wunddoku.pdf.PdfOpenResult import de.medapp.wunddoku.pdf.toUserMessage +import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog /** * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit @@ -201,6 +203,7 @@ fun DocumentationEditScreen( modifier = Modifier.fillMaxSize().padding(padding), block = currentBlock, photos = documentation.photos, + signatures = documentation.signatures, onCheckedChanged = viewModel::onCheckedChanged, onFreeTextChanged = viewModel::onFreeTextChanged, canGoBack = uiState.currentBlockIndex > 0, @@ -217,6 +220,9 @@ fun DocumentationEditScreen( }, onDeletePhotoRequested = viewModel::requestDeletePhoto, loadThumbnail = viewModel::loadPhotoThumbnail, + onAddSignatureClick = viewModel::requestAddSignature, + onDeleteSignatureRequested = viewModel::requestDeleteSignature, + loadSignatureBitmap = viewModel::loadSignatureBitmap, ) } } @@ -241,6 +247,54 @@ fun DocumentationEditScreen( }, ) } + + // MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene + // Navigationsroute, s. Aufgabenstellung Abschnitt 7). + if (uiState.showAddSignatureDialog) { + AddSignatureDialog( + onDismiss = viewModel::dismissAddSignature, + onSave = viewModel::saveSignature, + loadDefaultWoundCarer = viewModel::getDefaultWoundCarer, + searchWoundCarers = viewModel::searchWoundCarers, + loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap, + ) + } + + // Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung + // Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher + // deaktiviert, solange Name oder Zeichnung leer sind. + val signatureError = uiState.signatureError + if (signatureError != null) { + AlertDialog( + onDismissRequest = viewModel::dismissSignatureError, + title = { Text(stringResource(R.string.documentation_edit_signatures_title)) }, + text = { Text(signatureError) }, + confirmButton = { + TextButton(onClick = viewModel::dismissSignatureError) { + Text(stringResource(R.string.action_confirm)) + } + }, + ) + } + + val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId + if (pendingDeleteSignatureId != null) { + AlertDialog( + onDismissRequest = viewModel::dismissDeleteSignature, + title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) }, + text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) }, + confirmButton = { + TextButton(onClick = viewModel::confirmDeleteSignature) { + Text(stringResource(R.string.action_confirm)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::dismissDeleteSignature) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } } /** @@ -254,6 +308,7 @@ private fun DocumentationEditContent( modifier: Modifier = Modifier, block: DocumentationBlock, photos: List, + signatures: List, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, canGoBack: Boolean, @@ -266,6 +321,9 @@ private fun DocumentationEditContent( onAddPhotosClick: () -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, + onAddSignatureClick: () -> Unit, + onDeleteSignatureRequested: (signatureId: String) -> Unit, + loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, ) { LazyColumn( modifier = modifier, @@ -302,6 +360,47 @@ private fun DocumentationEditContent( } } + // MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt + // darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im + // späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003). + item(key = "signatures_header") { + Column { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.documentation_edit_signatures_title), + style = MaterialTheme.typography.titleMedium, + ) + OutlinedButton(onClick = onAddSignatureClick) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.documentation_edit_add_signature)) + } + } + } + } + + if (signatures.isEmpty()) { + item(key = "signatures_empty") { + Text( + stringResource(R.string.documentation_edit_no_signatures), + style = MaterialTheme.typography.bodySmall, + ) + } + } else { + items(signatures, key = { it.id }) { signature -> + SignatureRow( + signature = signature, + loadBitmap = loadSignatureBitmap, + onDeleteRequested = { onDeleteSignatureRequested(signature.id) }, + ) + } + } + item(key = "photos_header") { Column { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) @@ -440,6 +539,78 @@ private fun PhotoThumbnail( } } +/** + * Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und + * Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre + * Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) - + * analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel. + * Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es + * hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen. + */ +@Composable +private fun SignatureRow( + signature: Signature, + loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, + onDeleteRequested: () -> Unit, +) { + var bitmap by remember(signature.id) { mutableStateOf(null) } + var loadFailed by remember(signature.id) { mutableStateOf(false) } + + LaunchedEffect(signature.id) { + val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull() + bitmap = result + loadFailed = result == null + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .width(96.dp) + .size(96.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + ) { + val currentBitmap = bitmap + when { + currentBitmap != null -> Image( + bitmap = currentBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + + loadFailed -> Icon( + Icons.Filled.BrokenImage, + contentDescription = null, + modifier = Modifier.align(Alignment.Center), + ) + + else -> CircularProgressIndicator( + modifier = Modifier.size(24.dp).align(Alignment.Center), + strokeWidth = 2.dp, + ) + } + } + + Text( + text = signature.signerName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + + IconButton(onClick = onDeleteRequested) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.documentation_action_delete), + ) + } + } +} + @Composable private fun CheckItemRow( item: CheckItem, 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 18b97ab..326c4f3 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,6 @@ package de.medapp.wunddoku.ui.documentation.edit +import android.graphics.Bitmap import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.createSavedStateHandle @@ -8,8 +9,10 @@ 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.ui.navigation.Screen @@ -35,6 +38,14 @@ data class DocumentationEditUiState( 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 @@ -68,6 +79,7 @@ data class DocumentationEditUiState( class DocumentationEditViewModel( private val documentationId: String, private val repository: DocumentationRepository, + private val woundCarerRepository: WoundCarerRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(DocumentationEditUiState()) @@ -237,6 +249,68 @@ class DocumentationEditViewModel( } } + /** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */ + fun requestAddSignature() { + _uiState.update { it.copy(showAddSignatureDialog = true) } + } + + fun dismissAddSignature() { + _uiState.update { it.copy(showAddSignatureDialog = false) } + } + + /** + * Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische + * 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]). + * Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst + * NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s. + * [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern" + * bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte. + */ + fun saveSignature(signerName: String, bitmap: Bitmap) { + viewModelScope.launch { + try { + repository.saveSignature(documentationId, signerName, bitmap) + val refreshed = repository.getFullDocumentation(documentationId) + _uiState.update { state -> + state.copy( + documentation = refreshed ?: state.documentation, + showAddSignatureDialog = false, + ) + } + } catch (e: IllegalArgumentException) { + _uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") } + } + } + } + + fun dismissSignatureError() { + _uiState.update { it.copy(signatureError = null) } + } + + /** Öffnet die Löschbestätigung für eine Signatur (Aufgabenstellung Abschnitt 17: Signaturen + * sind unveränderlich - "korrigieren" bedeutet immer löschen + neu erfassen). */ + fun requestDeleteSignature(signatureId: String) { + _uiState.update { it.copy(pendingDeleteSignatureId = signatureId) } + } + + fun dismissDeleteSignature() { + _uiState.update { it.copy(pendingDeleteSignatureId = null) } + } + + fun confirmDeleteSignature() { + val signatureId = _uiState.value.pendingDeleteSignatureId ?: return + viewModelScope.launch { + repository.removeSignature(signatureId) + val refreshed = repository.getFullDocumentation(documentationId) + _uiState.update { state -> + state.copy( + documentation = refreshed ?: state.documentation, + pendingDeleteSignatureId = null, + ) + } + } + } + /** * Löst den PDF-Export an (PDF-001/002). Rein zum manuellen Testen der Export-Funktionalität * gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit @@ -291,6 +365,25 @@ class DocumentationEditViewModel( suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = repository.loadPhotoThumbnail(documentationId, fileName) + /** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog + * [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */ + suspend fun loadSignatureBitmap(documentationId: String, fileName: String) = + repository.loadSignatureBitmap(documentationId, fileName) + + /** Für die Vorbelegung des "Signatur hinzufügen"-Dialogs beim Öffnen: der zuletzt + * verwendete Wundversorger app-weit (nicht auf diese Dokumentation beschränkt) - `null`, + * wenn noch nie eine Signatur gespeichert wurde. Weiterhin änderbar (Änderungswunsch). */ + suspend fun getDefaultWoundCarer(): WoundCarer? = woundCarerRepository.getMostRecentlyUsed() + + /** Namensvorschläge für die Autovervollständigung im Signatur-Dialog. */ + suspend fun searchWoundCarers(query: String): List = 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 @@ -319,6 +412,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/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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c8932e..9634c39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,19 @@ 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