From 34de5b5c5ae0c4443497f38eeb06612717230954 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Fri, 21 Aug 2026 09:38:41 +0200 Subject: [PATCH 1/2] Formularformatierung --- app/build.gradle.kts | 2 +- .../2.json | 579 ++++++++++++++++++ .../medapp/wunddoku/data/dao/BlockStateDao.kt | 7 +- .../wunddoku/data/dao/CheckItemStateDao.kt | 4 + .../wunddoku/data/dao/DocumentationDao.kt | 22 +- .../wunddoku/data/database/AppDatabase.kt | 25 +- .../wunddoku/data/entity/BlockStateEntity.kt | 11 +- .../data/entity/CheckItemStateEntity.kt | 7 + .../data/entity/DocumentationEntity.kt | 9 + .../wunddoku/data/entity/PatientEntity.kt | 7 +- .../relations/DocumentationRelations.kt | 14 - .../data/mapper/DocumentationMapper.kt | 45 +- .../wunddoku/data/mapper/PatientMapper.kt | 12 +- .../repository/DocumentationRepository.kt | 141 +++-- .../data/repository/PatientRepository.kt | 4 +- .../de/medapp/wunddoku/di/AppContainer.kt | 1 + .../domain/catalog/DocumentationCatalog.kt | 18 +- .../medapp/wunddoku/domain/model/CheckItem.kt | 11 +- .../wunddoku/domain/model/Documentation.kt | 1 + .../domain/model/DocumentationBlock.kt | 11 +- .../medapp/wunddoku/domain/model/Patient.kt | 13 +- .../wunddoku/domain/model/PatientListItem.kt | 12 +- .../domain/model/PatientStammdatenSnapshot.kt | 32 + .../domain/usecase/BlockEditedPolicy.kt | 13 +- .../ui/components/format/FormattedInput.kt | 108 ++++ .../edit/DocumentationEditScreen.kt | 55 +- .../edit/DocumentationEditViewModel.kt | 35 +- .../ui/patients/form/PatientFormScreen.kt | 57 +- .../ui/patients/form/PatientFormViewModel.kt | 86 ++- .../ui/patients/list/PatientListScreen.kt | 2 +- app/src/main/res/values/strings.xml | 4 +- settings.gradle.kts | 3 - 32 files changed, 1148 insertions(+), 203 deletions(-) create mode 100644 app/schemas/de.medapp.wunddoku.data.database.AppDatabase/2.json create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/model/PatientStammdatenSnapshot.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/ui/components/format/FormattedInput.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f93742f..9652fc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,4 +153,4 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.compose.ui:ui-test-junit4") -} \ No newline at end of file +} diff --git a/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/2.json b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/2.json new file mode 100644 index 0000000..bf33c32 --- /dev/null +++ b/app/schemas/de.medapp.wunddoku.data.database.AppDatabase/2.json @@ -0,0 +1,579 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "1147eb9a721f44dcadce9449771d9766", + "entities": [ + { + "tableName": "patients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthDate", + "columnName": "birthDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "street", + "columnName": "street", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postalCode", + "columnName": "postalCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone3", + "columnName": "phone3", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_patients_lastName_firstName", + "unique": false, + "columnNames": [ + "lastName", + "firstName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)" + }, + { + "name": "index_patients_birthDate", + "unique": false, + "columnNames": [ + "birthDate" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "documentations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientId", + "columnName": "patientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "continuedFromDocumentationId", + "columnName": "continuedFromDocumentationId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pdfLastGeneratedAt", + "columnName": "pdfLastGeneratedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "catalogVersion", + "columnName": "catalogVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientFirstNameSnapshot", + "columnName": "patientFirstNameSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientLastNameSnapshot", + "columnName": "patientLastNameSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientBirthDateSnapshot", + "columnName": "patientBirthDateSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientStreetSnapshot", + "columnName": "patientStreetSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientPostalCodeSnapshot", + "columnName": "patientPostalCodeSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientCitySnapshot", + "columnName": "patientCitySnapshot", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_documentations_patientId_createdAt", + "unique": false, + "columnNames": [ + "patientId", + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)" + }, + { + "name": "index_documentations_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_documentations_continuedFromDocumentationId", + "unique": false, + "columnNames": [ + "continuedFromDocumentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)" + } + ], + "foreignKeys": [ + { + "table": "patients", + "onDelete": "RESTRICT", + "onUpdate": "NO ACTION", + "columns": [ + "patientId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "documentations", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "continuedFromDocumentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "block_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockDefinitionId", + "columnName": "blockDefinitionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "titleSnapshot", + "columnName": "titleSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockOrder", + "columnName": "blockOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_block_states_documentationId", + "unique": false, + "columnNames": [ + "documentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)" + }, + { + "name": "index_block_states_documentationId_blockDefinitionId", + "unique": true, + "columnNames": [ + "documentationId", + "blockDefinitionId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "check_item_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockStateId", + "columnName": "blockStateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checkItemDefinitionId", + "columnName": "checkItemDefinitionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labelSnapshot", + "columnName": "labelSnapshot", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemOrder", + "columnName": "itemOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checked", + "columnName": "checked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFreeText", + "columnName": "isFreeText", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeTextValue", + "columnName": "freeTextValue", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_check_item_states_blockStateId", + "unique": false, + "columnNames": [ + "blockStateId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)" + }, + { + "name": "index_check_item_states_blockStateId_checkItemDefinitionId", + "unique": true, + "columnNames": [ + "blockStateId", + "checkItemDefinitionId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)" + } + ], + "foreignKeys": [ + { + "table": "block_states", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "blockStateId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "photos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_photos_documentationId_orderIndex", + "unique": false, + "columnNames": [ + "documentationId", + "orderIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "signatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT, `fileName` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentationId", + "columnName": "documentationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerName", + "columnName": "signerName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_signatures_documentationId", + "unique": false, + "columnNames": [ + "documentationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)" + } + ], + "foreignKeys": [ + { + "table": "documentations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1147eb9a721f44dcadce9449771d9766')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt index 275ae25..f8e099c 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt @@ -7,9 +7,7 @@ import de.medapp.wunddoku.data.entity.BlockStateEntity /** * Bewusst schlanke DAO: Nur Datenbankzugriffe, keine Koordination mehrerer Tabellen (das - * übernimmt DocumentationRepository). Kein `update` für ganze Entities nötig, da nur - * `freeText`/`updatedAt` sich nach dem Erstellen je ändern (Autosave) – ein gezieltes - * Teil-Update ist hier klarer als ein vollständiges Entity-Update. + * übernimmt DocumentationRepository). */ @Dao interface BlockStateDao { @@ -22,7 +20,4 @@ interface BlockStateDao { @Query("SELECT * FROM block_states WHERE documentationId = :documentationId ORDER BY blockOrder") suspend fun getForDocumentation(documentationId: String): List - - @Query("UPDATE block_states SET freeText = :freeText, updatedAt = :updatedAt WHERE id = :id") - suspend fun updateFreeText(id: String, freeText: String, updatedAt: Long) } diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt index 92394ba..abb0e5c 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt @@ -16,4 +16,8 @@ interface CheckItemStateDao { @Query("UPDATE check_item_states SET checked = :checked WHERE id = :id") suspend fun setChecked(id: String, checked: Boolean) + + /** Speichert den Freitext der Freitext-Option (isFreeText = true) eines Blocks. */ + @Query("UPDATE check_item_states SET freeTextValue = :value WHERE id = :id") + suspend fun updateFreeTextValue(id: String, value: String) } diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt index cddd351..0ceafb9 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt @@ -8,7 +8,6 @@ import androidx.room.Transaction import androidx.room.Update import de.medapp.wunddoku.data.entity.DocumentationEntity import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails -import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient import kotlinx.coroutines.flow.Flow @Dao @@ -27,15 +26,22 @@ interface DocumentationDao { suspend fun getById(id: String): DocumentationEntity? /** - * Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen mit - * Patientenbezug, ohne die vollständigen Blöcke zu laden – bewusst schlank für Performance - * bei größeren Datenmengen. + * Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen. + * Liefert direkt [DocumentationEntity] statt einer Relation mit Patient - Name/Adresse für + * die Anzeige stehen bereits als Snapshot auf der Dokumentation selbst (siehe + * DocumentationEntity.patientFirstNameSnapshot etc.), ein JOIN mit der Patient-Tabelle ist + * für die reine Listenanzeige nicht mehr nötig. */ - @Transaction @Query("SELECT * FROM documentations ORDER BY createdAt DESC") - fun observeAllWithPatient(): Flow> + fun observeAll(): Flow> - @Transaction + /** + * Sucht bewusst weiterhin gegen den AKTUELLEN Patientennamen (JOIN mit patients), nicht + * gegen den eingefrorenen Namens-Snapshot der jeweiligen Dokumentation: Ein Nutzer sucht + * "nach dem Patienten, den ich kenne" (aktueller, echter Name), nicht nach einem + * möglicherweise veralteten historischen Namen. Die zurückgegebenen Zeilen selbst zeigen + * dann trotzdem den Snapshot-Namen der jeweiligen Dokumentation an (siehe toSummary()). + */ @Query( """ SELECT d.* FROM documentations d @@ -46,7 +52,7 @@ interface DocumentationDao { ORDER BY d.createdAt DESC """ ) - fun search(query: String): Flow> + fun search(query: String): Flow> @Transaction @Query("SELECT * FROM documentations WHERE id = :id") diff --git a/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt index 71461fa..a196638 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 @@ -21,8 +21,23 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter /** * Zentrale Room-Datenbank. `exportSchema = true`, damit Room die Schema-Historie als JSON in - * app/schemas/ ablegt (im Gradle-Skript über `room { schemaDirectory(...) }` zu konfigurieren, - * s. Kommentar unten) – das ist die Grundlage für spätere, nachvollziehbare Migrationen. + * app/schemas/ ablegt – Grundlage für nachvollziehbare Migrationen. + * + * Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung - + * Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von + * CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf + * DocumentationEntity. + * + * `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). */ @Database( entities = [ @@ -33,7 +48,7 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter PhotoEntity::class, SignatureEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) @TypeConverters(DocumentationStatusConverter::class) @@ -58,7 +73,9 @@ abstract class AppDatabase : RoomDatabase() { context.applicationContext, AppDatabase::class.java, DATABASE_NAME, - ).build().also { instance = it } + ) + .fallbackToDestructiveMigration() // s. Klassenkommentar: vorübergehend, vor Produktivbetrieb ersetzen + .build().also { instance = it } } } } diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt index 5e476e5..a23326d 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt @@ -15,9 +15,13 @@ import androidx.room.PrimaryKey * Foreign Key: Der Katalog ist keine Datenbanktabelle, sondern Kotlin-Code. * * [titleSnapshot]/[blockOrder] speichern den zum Erstellungszeitpunkt der Dokumentation - * gültigen Titel bzw. die gültige Reihenfolge (Architektur-Dokument Abschnitt 2.4) – das - * stellt sicher, dass eine später geöffnete, historische Dokumentation unverändert korrekt - * dargestellt wird, selbst wenn sich der Katalog danach ändert. + * gültigen Titel bzw. die gültige Reihenfolge - das stellt sicher, dass eine später geöffnete, + * historische Dokumentation unverändert korrekt dargestellt wird, selbst wenn sich der Katalog + * danach ändert. + * + * Kein `freeText`-Feld mehr: Der Freitext eines Blocks wird seit der Umstellung auf eine + * Checkbox-Option als CheckItemStateEntity mit `isFreeText = true` geführt (siehe dort), nicht + * mehr als eigenes Feld am Block. */ @Entity( tableName = "block_states", @@ -40,6 +44,5 @@ data class BlockStateEntity( val blockDefinitionId: String, val titleSnapshot: String, val blockOrder: Int, - val freeText: String, val updatedAt: Long, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt index 9494753..3269dac 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt @@ -10,6 +10,11 @@ import androidx.room.PrimaryKey * * [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche * Begründung, auf Checkbox-Ebene. + * + * [isFreeText]/[freeTextValue]: Jeder Block bekommt beim Erstellen automatisch genau eine + * zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des + * Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am + * Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer. */ @Entity( tableName = "check_item_states", @@ -33,4 +38,6 @@ data class CheckItemStateEntity( val labelSnapshot: String, val itemOrder: Int, val checked: Boolean, + val isFreeText: Boolean = false, + val freeTextValue: String = "", ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt index f30125c..034f3e1 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt @@ -49,4 +49,13 @@ data class DocumentationEntity( val continuedFromDocumentationId: String?, val pdfLastGeneratedAt: Long?, val catalogVersion: Int, + // Patienten-Stammdaten-Snapshot zum Erstellungszeitpunkt dieser Dokumentation - siehe + // ausführliche Begründung in domain/model/PatientStammdatenSnapshot.kt. Frisch befüllt bei + // createEmpty()/continueDocumentation() im DocumentationRepository, danach unveränderlich. + val patientFirstNameSnapshot: String, + val patientLastNameSnapshot: String, + val patientBirthDateSnapshot: String, + val patientStreetSnapshot: String, + val patientPostalCodeSnapshot: String, + val patientCitySnapshot: String, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt index d98dd2d..1670f4e 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt @@ -13,6 +13,9 @@ import androidx.room.PrimaryKey * TypeConverter auf LocalDate: Der String ist bereits das native SQLite-Format, direkt * sortierbar und ohne Konverter lesbar. Der Mapper (PatientMapper) übernimmt die Umwandlung * zu/von `java.time.LocalDate` beim Übergang zur Domain-Schicht. + * Die Adresse wird atomar gespeichert (street/postalCode/city) statt als einzelnes Freitextfeld + * - ermöglicht z. B. eine passende Tastatur für die PLZ-Eingabe und vermeidet, dass die + * Persistenzschicht auf unstrukturiertem Freitext basiert. */ @Entity( tableName = "patients", @@ -26,7 +29,9 @@ data class PatientEntity( val firstName: String, val lastName: String, val birthDate: String, - val address: String, + val street: String, + val postalCode: String, + val city: String, val phone1: String, val phone2: String?, val phone3: String?, diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt index 752aea7..8bef5ab 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt @@ -5,7 +5,6 @@ import androidx.room.Relation import de.medapp.wunddoku.data.entity.BlockStateEntity import de.medapp.wunddoku.data.entity.CheckItemStateEntity import de.medapp.wunddoku.data.entity.DocumentationEntity -import de.medapp.wunddoku.data.entity.PatientEntity import de.medapp.wunddoku.data.entity.PhotoEntity import de.medapp.wunddoku.data.entity.SignatureEntity @@ -56,16 +55,3 @@ data class DocumentationWithDetails( ) val signatures: List, ) - -/** - * Schlanke Relation für Listenansichten: Dokumentation + zugehöriger Patient, ohne Blöcke/ - * Fotos/Unterschriften zu laden. Wird für die Wunddokumentationsliste (Startscreen) verwendet. - */ -data class DocumentationWithPatient( - @Embedded val documentation: DocumentationEntity, - @Relation( - parentColumn = "patientId", - entityColumn = "id", - ) - val patient: PatientEntity, -) diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt index a4812f1..520bc14 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt @@ -1,26 +1,26 @@ package de.medapp.wunddoku.data.mapper -import de.medapp.wunddoku.data.entity.BlockStateEntity import de.medapp.wunddoku.data.entity.CheckItemStateEntity +import de.medapp.wunddoku.data.entity.DocumentationEntity import de.medapp.wunddoku.data.entity.relations.BlockStateWithItems import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails -import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient import de.medapp.wunddoku.domain.model.CheckItem import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.DocumentationBlock import de.medapp.wunddoku.domain.model.DocumentationSummary +import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot import java.time.Instant +import java.time.LocalDate /** - * Wandelt zwischen den Room-Relations und den Domain-Modellen [Documentation]/ - * [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern - * ([BlockStateEntity.titleSnapshot], [CheckItemStateEntity.labelSnapshot]) – hier findet KEINE - * Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt (Architektur- - * Dokument Abschnitt 2.4). + * Wandelt zwischen den Room-Relations/Entities und den Domain-Modellen [Documentation]/ + * [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern - hier + * findet KEINE Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt. */ fun DocumentationWithDetails.toDomain(): Documentation = Documentation( id = documentation.id, patientId = documentation.patientId, + patientSnapshot = documentation.toPatientSnapshot(), status = documentation.status, createdAt = Instant.ofEpochMilli(documentation.createdAt), updatedAt = Instant.ofEpochMilli(documentation.updatedAt), @@ -56,7 +56,6 @@ fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock( definitionId = blockState.blockDefinitionId, title = blockState.titleSnapshot, order = blockState.blockOrder, - freeText = blockState.freeText, items = checkItems .sortedBy { it.itemOrder } .map { it.toDomain() }, @@ -68,13 +67,29 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem( label = labelSnapshot, order = itemOrder, checked = checked, + isFreeText = isFreeText, + freeTextValue = freeTextValue, ) -fun DocumentationWithPatient.toSummary(): DocumentationSummary = DocumentationSummary( - id = documentation.id, - patientId = documentation.patientId, - patientFullName = "${patient.firstName} ${patient.lastName}", - status = documentation.status, - createdAt = Instant.ofEpochMilli(documentation.createdAt), - updatedAt = Instant.ofEpochMilli(documentation.updatedAt), +/** + * Liefert direkt aus [DocumentationEntity] eine [DocumentationSummary] für Listenansichten - + * ohne Join/Relation mit der Patient-Tabelle, da Name für die Anzeige bereits als Snapshot auf + * der Dokumentation selbst liegt (siehe PatientStammdatenSnapshot-Dokumentation). + */ +fun DocumentationEntity.toSummary(): DocumentationSummary = DocumentationSummary( + id = id, + patientId = patientId, + patientFullName = "$patientFirstNameSnapshot $patientLastNameSnapshot", + status = status, + createdAt = Instant.ofEpochMilli(createdAt), + updatedAt = Instant.ofEpochMilli(updatedAt), +) + +fun DocumentationEntity.toPatientSnapshot(): PatientStammdatenSnapshot = PatientStammdatenSnapshot( + firstName = patientFirstNameSnapshot, + lastName = patientLastNameSnapshot, + birthDate = LocalDate.parse(patientBirthDateSnapshot), + street = patientStreetSnapshot, + postalCode = patientPostalCodeSnapshot, + city = patientCitySnapshot, ) diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt index 4a3d881..4a3fdff 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt @@ -10,16 +10,16 @@ import java.time.LocalDate * * Kotlin-Hinweis: Dies sind sogenannte Extension Functions (`fun PatientEntity.toDomain()`) * – sie erweitern eine bestehende Klasse um eine Methode, ohne die Klasse selbst zu verändern - * oder von ihr zu erben. Der Aufruf sieht dadurch wie eine normale Methode aus: - * `patientEntity.toDomain()`. Praktisch, um Mapping-Logik nahe an den beteiligten Typen zu - * halten, ohne eine eigene Mapper-Klasse mit Konstruktor-Injection zu benötigen. + * oder von ihr zu erben. */ fun PatientEntity.toDomain(): Patient = Patient( id = id, firstName = firstName, lastName = lastName, birthDate = LocalDate.parse(birthDate), - address = address, + street = street, + postalCode = postalCode, + city = city, phone1 = phone1, phone2 = phone2, phone3 = phone3, @@ -32,7 +32,9 @@ fun Patient.toEntity(): PatientEntity = PatientEntity( firstName = firstName, lastName = lastName, birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd) - address = address, + street = street, + postalCode = postalCode, + city = city, phone1 = phone1, phone2 = phone2, phone3 = phone3, diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt index 30abece..1fee058 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt @@ -6,6 +6,7 @@ import androidx.room.withTransaction import de.medapp.wunddoku.data.dao.BlockStateDao import de.medapp.wunddoku.data.dao.CheckItemStateDao import de.medapp.wunddoku.data.dao.DocumentationDao +import de.medapp.wunddoku.data.dao.PatientDao import de.medapp.wunddoku.data.dao.PhotoDao import de.medapp.wunddoku.data.dao.SignatureDao import de.medapp.wunddoku.data.database.AppDatabase @@ -33,7 +34,10 @@ import java.util.UUID * Koordiniert alle Lese-/Schreiboperationen rund um eine Wunddokumentation über mehrere DAOs * hinweg. ViewModels sehen niemals mehr als dieses eine Repository für Dokumentations-bezogene * Daten – Mehrfach-DAO-Koordination und Room-Transaktionen (`db.withTransaction`) sind - * ausschließlich hier gebündelt (Architektur-Dokument Abschnitt 5). + * ausschließlich hier gebündelt. + * + * [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein + * frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation). */ class DocumentationRepository( private val documentationDao: DocumentationDao, @@ -41,13 +45,14 @@ class DocumentationRepository( private val checkItemStateDao: CheckItemStateDao, private val photoDao: PhotoDao, private val signatureDao: SignatureDao, + private val patientDao: PatientDao, private val photoStorage: PhotoStorage, private val signatureFileStorage: SignatureFileStorage, private val db: AppDatabase, ) { fun observeAll(): Flow> = - documentationDao.observeAllWithPatient().map { list -> list.map { it.toSummary() } } + documentationDao.observeAll().map { list -> list.map { it.toSummary() } } fun search(query: String): Flow> = documentationDao.search(query).map { list -> list.map { it.toSummary() } } @@ -57,13 +62,15 @@ class DocumentationRepository( /** * Legt eine neue, leere Wunddokumentation für [patientId] an: eine [DocumentationEntity] - * plus je einen [BlockStateEntity] pro Katalog-Block (mit Label-/Titel-Snapshot aus dem - * *aktuellen* Katalogstand, Architektur-Dokument Abschnitt 2.4) und je einen - * [CheckItemStateEntity] pro Katalog-Checkbox. Läuft in einer einzigen Room-Transaktion, - * damit niemals eine halb angelegte Dokumentation (z. B. Documentation-Zeile ohne - * zugehörige Blöcke) entstehen kann. + * (inkl. frischem Patienten-Stammdaten-Snapshot aus der aktuellen Patient-Tabelle) plus je + * einen [BlockStateEntity] pro Katalog-Block (mit Titel-/Reihenfolge-Snapshot aus dem + * *aktuellen* Katalogstand) und je einen [CheckItemStateEntity] pro Katalog-Checkbox, PLUS + * eine zusätzliche Freitext-Option pro Block (isFreeText = true). Läuft in einer einzigen + * Room-Transaktion, damit niemals eine halb angelegte Dokumentation entstehen kann. */ suspend fun createEmpty(patientId: String): Documentation = db.withTransaction { + val patient = patientDao.getById(patientId) + ?: error("Patient $patientId nicht gefunden") val now = Instant.now().toEpochMilli() val documentationId = UUID.randomUUID().toString() @@ -77,6 +84,12 @@ class DocumentationRepository( continuedFromDocumentationId = null, pdfLastGeneratedAt = null, catalogVersion = DocumentationCatalog.CATALOG_VERSION, + patientFirstNameSnapshot = patient.firstName, + patientLastNameSnapshot = patient.lastName, + patientBirthDateSnapshot = patient.birthDate, + patientStreetSnapshot = patient.street, + patientPostalCodeSnapshot = patient.postalCode, + patientCitySnapshot = patient.city, ) ) @@ -86,19 +99,21 @@ class DocumentationRepository( } /** - * "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9, präzisiert in Architektur- - * Dokument Abschnitt 6): + * "Dokumentation fortsetzen": * - Patient: gleiche patientId, kein neuer Patientendatensatz - * - BlockState/CheckItemState: vollständig von der Quelle kopiert, inkl. Snapshots und - * Reihenfolge der QUELLE (nicht neu aus dem aktuellen Katalog gezogen) – die fortgesetzte - * Dokumentation beginnt exakt auf dem inhaltlichen Stand des Vorgängers. + * - Patienten-Stammdaten-Snapshot: wird FRISCH aus dem *aktuellen* Patientendatensatz + * gebildet (nicht von der Quelldokumentation kopiert) - die neue, fortgesetzte + * Dokumentation soll den zum jetzigen Zeitpunkt aktuellen Stand des Patienten abbilden + * (z. B. nach einem Umzug), während die Quelldokumentation ihren eigenen, damaligen + * Snapshot unverändert behält. + * - BlockState/CheckItemState (Checkboxzustände, Freitexte, Snapshots, Reihenfolge): + * vollständig von der Quelle kopiert, inkl. Freitext-Option * - createdAt/updatedAt: neu * - status: IN_PROGRESS * - continuedFromDocumentationId: ID der Quelle - * - Unterschriften: werden NICHT kopiert (eine Unterschrift bestätigt nur die konkrete - * Dokumentation, unter der sie geleistet wurde) - * - Fotos: über [copyPhotos] steuerbar, Standard `false` – fachlich noch nicht final - * entschieden (siehe Architektur-Dokument Abschnitt 6) + * - Unterschriften: werden NICHT kopiert + * - Fotos: über [copyPhotos] steuerbar, Standard `false` - fachlich noch nicht final + * entschieden */ suspend fun continueDocumentation( sourceDocumentationId: String, @@ -106,6 +121,8 @@ class DocumentationRepository( ): Documentation = db.withTransaction { val source = documentationDao.getFullDocumentation(sourceDocumentationId) ?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden") + val patient = patientDao.getById(source.documentation.patientId) + ?: error("Patient ${source.documentation.patientId} nicht gefunden") val now = Instant.now().toEpochMilli() val newDocumentationId = UUID.randomUUID().toString() @@ -120,10 +137,16 @@ class DocumentationRepository( continuedFromDocumentationId = sourceDocumentationId, pdfLastGeneratedAt = null, catalogVersion = source.documentation.catalogVersion, + patientFirstNameSnapshot = patient.firstName, + patientLastNameSnapshot = patient.lastName, + patientBirthDateSnapshot = patient.birthDate, + patientStreetSnapshot = patient.street, + patientPostalCodeSnapshot = patient.postalCode, + patientCitySnapshot = patient.city, ) ) - // Blöcke + Checkboxen 1:1 von der Quelle übernehmen (inkl. Snapshots/Reihenfolge) + // Blöcke + Checkboxen (inkl. Freitext-Option) 1:1 von der Quelle übernehmen for (sourceBlock in source.blocks) { val newBlockStateId = UUID.randomUUID().toString() blockStateDao.insertAll( @@ -134,7 +157,6 @@ class DocumentationRepository( blockDefinitionId = sourceBlock.blockState.blockDefinitionId, titleSnapshot = sourceBlock.blockState.titleSnapshot, blockOrder = sourceBlock.blockState.blockOrder, - freeText = sourceBlock.blockState.freeText, updatedAt = now, ) ) @@ -148,6 +170,8 @@ class DocumentationRepository( labelSnapshot = item.labelSnapshot, itemOrder = item.itemOrder, checked = item.checked, + isFreeText = item.isFreeText, + freeTextValue = item.freeTextValue, ) } ) @@ -174,25 +198,31 @@ class DocumentationRepository( } /** - * Komfort-Methode für die Patientenliste (Aufgabenstellung Abschnitt 6, Aktion - * "Behandlung/Dokumentation fortsetzen"): ermittelt die zuletzt erstellte Dokumentation - * eines Patienten und ruft darauf [continueDocumentation] auf. Liefert `null`, wenn der - * Patient noch keine Dokumentation besitzt (UI muss diesen Fall abfangen, s. Aufgabenstellung - * Abschnitt 20). + * Komfort-Methode für die Patientenliste ("Behandlung/Dokumentation fortsetzen"): ermittelt + * die zuletzt erstellte Dokumentation eines Patienten und ruft darauf + * [continueDocumentation] auf. Liefert `null`, wenn der Patient noch keine Dokumentation + * besitzt. */ suspend fun continueLatestForPatient(patientId: String): Documentation? { val latestId = documentationDao.getLatestForPatient(patientId)?.documentation?.id ?: return null return continueDocumentation(latestId) } - suspend fun updateFreeText(blockStateId: String, text: String) { - blockStateDao.updateFreeText(blockStateId, text, Instant.now().toEpochMilli()) - } - + /** Speichert den Zustand einer normalen Checkbox ODER der Freitext-Checkbox eines Blocks. */ suspend fun setChecked(checkItemStateId: String, checked: Boolean) { checkItemStateDao.setChecked(checkItemStateId, checked) } + /** + * Speichert den Freitext der Freitext-Option eines Blocks. Der Freitext ist seit der + * Umstellung auf eine Checkbox-Option kein Feld am BlockState mehr, sondern liegt am + * jeweiligen CheckItemState (isFreeText = true) - [checkItemStateId] referenziert deshalb + * dieses CheckItem, nicht den Block. + */ + suspend fun updateFreeText(checkItemStateId: String, text: String) { + checkItemStateDao.updateFreeTextValue(checkItemStateId, text) + } + suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { val fileName = photoStorage.copyFrom(sourceUri) val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 @@ -209,20 +239,15 @@ class DocumentationRepository( } /** - * Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Eine Room-Transaktion - * kann keine Dateisystemoperation einschließen. Reihenfolge: DB-Zeile zuerst, dann Datei – - * schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei zurück (unkritisch), - * statt eines DB-Eintrags, der auf eine nicht mehr existente Datei verweist (würde beim - * Anzeigen/PDF-Export zu einem sichtbaren Fehler führen). Siehe Architektur-Dokument - * Abschnitt 5.1. + * Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Reihenfolge DB-Zeile + * zuerst, dann Datei - schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei + * zurück (unkritisch), statt eines DB-Eintrags, der auf eine nicht mehr existente Datei + * verweist. */ suspend fun removePhoto(photoId: String) { val photo = photoDao.getById(photoId) ?: return photoDao.delete(photo) runCatching { photoStorage.delete(photo.fileName) } - // Fehler beim Löschen der physischen Datei wird bewusst verschluckt: Die DB ist bereits - // konsistent, eine verwaiste Datei ist der unkritischere Fehlerfall. Kein Logging von - // Dateinamen mit Patientenbezug, s. Datenschutz-Abschnitt der Architektur. } suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { @@ -248,9 +273,6 @@ class DocumentationRepository( suspend fun delete(documentationId: String) { val entity = documentationDao.getById(documentationId) ?: return - // Zugehörige Fotos/Signaturen zuerst physisch löschen (best effort), dann die - // Dokumentation selbst – CASCADE übernimmt das Entfernen der DB-Zeilen für - // BlockState/CheckItemState/Photo/Signature. val photos = photoDao.getForDocumentation(documentationId) val signatures = signatureDao.getForDocumentation(documentationId) documentationDao.delete(entity) @@ -258,6 +280,12 @@ class DocumentationRepository( signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } } + /** + * Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro + * Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option + * (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL + * und BlockDefinition.freeTextDefinitionId. + */ private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { for (blockDef in DocumentationCatalog.blocks) { val blockStateId = UUID.randomUUID().toString() @@ -269,23 +297,34 @@ class DocumentationRepository( blockDefinitionId = blockDef.id, titleSnapshot = blockDef.title, blockOrder = blockDef.order, - freeText = "", updatedAt = now, ) ) ) - checkItemStateDao.insertAll( - blockDef.items.map { itemDef -> - CheckItemStateEntity( - id = UUID.randomUUID().toString(), - blockStateId = blockStateId, - checkItemDefinitionId = itemDef.id, - labelSnapshot = itemDef.label, - itemOrder = itemDef.order, - checked = false, - ) - } + + val normalItems = blockDef.items.map { itemDef -> + CheckItemStateEntity( + id = UUID.randomUUID().toString(), + blockStateId = blockStateId, + checkItemDefinitionId = itemDef.id, + labelSnapshot = itemDef.label, + itemOrder = itemDef.order, + checked = false, + isFreeText = false, + freeTextValue = "", + ) + } + val freeTextItem = CheckItemStateEntity( + id = UUID.randomUUID().toString(), + blockStateId = blockStateId, + checkItemDefinitionId = blockDef.freeTextDefinitionId, + labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, + itemOrder = blockDef.items.size + 1, + checked = false, + isFreeText = true, + freeTextValue = "", ) + checkItemStateDao.insertAll(normalItems + freeTextItem) } } } diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt index 9574b13..b3484fa 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt @@ -67,7 +67,9 @@ class PatientRepository( id = patient.id, fullName = "${patient.firstName} ${patient.lastName}", birthDate = LocalDate.parse(patient.birthDate), - address = patient.address, + street = patient.street, + postalCode = patient.postalCode, + city = patient.city, lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) }, ) } diff --git a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt index 315d3a8..0d3c714 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -39,6 +39,7 @@ class AppContainer(context: Context) { checkItemStateDao = database.checkItemStateDao(), photoDao = database.photoDao(), signatureDao = database.signatureDao(), + patientDao = database.patientDao(), photoStorage = photoStorage, signatureFileStorage = signatureFileStorage, db = database, diff --git a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt index 33fcc7a..2cf4ff6 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt @@ -22,6 +22,14 @@ object DocumentationCatalog { const val CATALOG_VERSION: Int = 1 + /** + * Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch: + * "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral + * hier definiert, damit Repository (Erstellung/Fortsetzen) und ggf. der spätere PDF-Export + * dieselbe Bezeichnung verwenden. + */ + const val FREE_TEXT_LABEL: String = "Freitext" + val blocks: List = listOf( BlockDefinition( id = "block_01", @@ -64,7 +72,15 @@ data class BlockDefinition( val title: String, val order: Int, val items: List, -) +) { + /** + * Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte Freitext-Option + * dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein Bestandteil von [items], + * da die Freitext-Option kein fachlicher Katalog-Eintrag ist, sondern strukturell zu jedem + * Block gehört. + */ + val freeTextDefinitionId: String get() = "${id}_freetext" +} data class CheckItemDefinition( val id: String, diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt index 713cf9a..8526ce7 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt @@ -7,7 +7,14 @@ package de.medapp.wunddoku.domain.model * (CheckItemState.labelSnapshot in der Datenbank), NICHT aus einer Laufzeit-Auflösung des * aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Das stellt sicher, dass * historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt - * dargestellt werden. Siehe Architektur-Dokument Abschnitt 2.4. + * dargestellt werden. + * + * [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am + * Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block + * bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`, + * ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren + * PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu + * unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen. */ data class CheckItem( val checkItemStateId: String, @@ -15,4 +22,6 @@ data class CheckItem( val label: String, val order: Int, val checked: Boolean, + val isFreeText: Boolean = false, + val freeTextValue: String = "", ) diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt index 0c09d97..215101d 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt @@ -16,6 +16,7 @@ import java.time.Instant data class Documentation( val id: String, val patientId: String, + val patientSnapshot: PatientStammdatenSnapshot, val status: DocumentationStatus, val createdAt: Instant, val updatedAt: Instant, diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt index 922f43d..63a2b0c 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt @@ -7,12 +7,12 @@ import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy * Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht * aus einer Laufzeit-Auflösung des Katalogs. * + * Kein eigenes `freeText`-Feld mehr: Der Freitext eines Blocks ist eine der Einträge in [items] + * (dasjenige CheckItem mit `isFreeText = true`) - siehe [CheckItem]. + * * [isEdited] wird bewusst NICHT in der Datenbank gespeichert, sondern hier als abgeleitete - * Eigenschaft berechnet ("computed property", kein eigenes Feld im Konstruktor). Das - * verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen Inhalt abweichen kann. - * [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert – sowohl die UI - * (Fortschrittsanzeige) als auch der spätere PDF-Export verwenden ausschließlich diese - * Eigenschaft. Siehe Architektur-Dokument Abschnitt 2.3c. + * Eigenschaft berechnet. Das verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen + * Inhalt abweichen kann. [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert. */ data class DocumentationBlock( val blockStateId: String, @@ -20,7 +20,6 @@ data class DocumentationBlock( val title: String, val order: Int, val items: List, - val freeText: String, ) { val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this) } diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt index f71d0c6..824b8fb 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt @@ -9,16 +9,20 @@ import java.time.LocalDate * * Vor- und Nachname werden getrennt gespeichert statt als ein "name"-Feld: Das ermöglicht * separate Suche/Sortierung nach Nachnamen (in Kliniken/Pflege üblich, z. B. "Nachname, - * Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen - * (z. B. Anrede + Nachname in Dokumenten), ohne den Namen nachträglich wieder aufteilen zu - * müssen. + * Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen. + * + * Die Adresse wird ebenso atomar erfasst ([street], [postalCode], [city]) statt als einzelnes + * Freitextfeld: ermöglicht z. B. spätere PLZ-basierte Auswertungen oder Validierung, ohne die + * Adresse nachträglich wieder zerlegen zu müssen. */ data class Patient( val id: String, val firstName: String, val lastName: String, val birthDate: LocalDate, - val address: String, + val street: String, + val postalCode: String, + val city: String, val phone1: String, val phone2: String?, val phone3: String?, @@ -26,4 +30,5 @@ data class Patient( val updatedAt: Instant, ) { val fullName: String get() = "$firstName $lastName" + val displayAddress: String get() = "$street, $postalCode $city" } diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt index 9ff7370..2e75d87 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt @@ -4,14 +4,18 @@ import java.time.Instant import java.time.LocalDate /** - * Schlanke Projektion für die Patientenliste (Architektur-Dokument Abschnitt 6), inklusive - * Datum der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die + * Schlanke Projektion für die Patientenliste (Aufgabenstellung Abschnitt 6), inklusive Datum + * der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die * Listen-Query nicht implizit an eine bestimmte Detaildarstellung gekoppelt ist. */ data class PatientListItem( val id: String, val fullName: String, val birthDate: LocalDate, - val address: String, + val street: String, + val postalCode: String, + val city: String, val lastDocumentationAt: Instant?, -) +) { + val displayAddress: String get() = "$street, $postalCode $city" +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientStammdatenSnapshot.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientStammdatenSnapshot.kt new file mode 100644 index 0000000..a8e397e --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientStammdatenSnapshot.kt @@ -0,0 +1,32 @@ +package de.medapp.wunddoku.domain.model + +import java.time.LocalDate + +/** + * Zum Erstellungszeitpunkt einer Wunddokumentation eingefrorener Stand der relevanten + * Patienten-Stammdaten (Name, Geburtsdatum, Adresse). + * + * Hintergrund: Vor dieser Änderung referenzierte [Documentation] den Patienten ausschließlich + * über [Documentation.patientId] und las Name/Adresse für die Anzeige live aus der aktuellen + * [Patient]-Tabelle. Das hätte bedeutet, dass eine spätere Änderung der Patienten-Stammdaten + * (z. B. Umzug) rückwirkend die Anzeige bereits bestehender, historischer Dokumentationen + * verändert hätte - fachlich nicht akzeptabel für ein Dokumentationssystem mit Patientendaten. + * + * Analog zum bereits bestehenden Snapshot-Konzept für Block-/Checkbox-Inhalte (titleSnapshot/ + * labelSnapshot, siehe [DocumentationBlock]/[CheckItem]) wird dieser Snapshot beim Erstellen + * bzw. beim "Fortsetzen" einer Dokumentation jeweils FRISCH aus dem *aktuellen* Patientendatensatz + * gebildet (nicht von einer Vorgänger-Dokumentation kopiert) - eine neue Dokumentation soll den + * zum Zeitpunkt ihrer Erstellung aktuellen Stand des Patienten abbilden, während ältere + * Dokumentationen ihren eigenen, damaligen Stand unverändert behalten. + */ +data class PatientStammdatenSnapshot( + val firstName: String, + val lastName: String, + val birthDate: LocalDate, + val street: String, + val postalCode: String, + val city: String, +) { + val fullName: String get() = "$firstName $lastName" + val displayAddress: String get() = "$street, $postalCode $city" +} diff --git a/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt index eb0ba07..46fcef3 100644 --- a/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt +++ b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt @@ -15,9 +15,16 @@ import de.medapp.wunddoku.domain.model.DocumentationBlock */ object BlockEditedPolicy { + /** + * Ein Block gilt als bearbeitet, sobald mindestens eine seiner Optionen angehakt ist - das + * schließt die Freitext-Option (CheckItem.isFreeText = true) automatisch mit ein, da sie + * ebenfalls über [CheckItem.checked] gesetzt wird. Eine zusätzliche Prüfung auf vorhandenen + * Freitext-Inhalt ist seit der Umstellung des Freitexts auf eine Checkbox-Option nicht mehr + * nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt + * ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon + * Text eingegeben wurde. + */ fun isEdited(block: DocumentationBlock): Boolean { - val hasFreeText = block.freeText.isNotBlank() - val hasCheckedItem = block.items.any { it.checked } - return hasFreeText || hasCheckedItem + return block.items.any { it.checked } } } diff --git a/app/src/main/java/de/medapp/wunddoku/ui/components/format/FormattedInput.kt b/app/src/main/java/de/medapp/wunddoku/ui/components/format/FormattedInput.kt new file mode 100644 index 0000000..0e58985 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/ui/components/format/FormattedInput.kt @@ -0,0 +1,108 @@ +package de.medapp.wunddoku.ui.components.format + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import java.time.LocalDate + +/** + * Zentrale, wiederverwendbare Formatierungs-/Transformationslösung für ziffernbasierte + * Eingabefelder (Geburtsdatum, Telefonnummern). Bewusst als EINE generische Klasse statt + * getrennter Lösungen pro Feldtyp, damit die Formatierungslogik nicht auf mehrere Screens + * verteilt wird. + * + * Funktionsweise: Der zugrunde liegende Wert (`value` im TextField) bleibt immer eine reine + * Ziffernfolge ohne Trennzeichen - das ist die Quelle der Wahrheit im ViewModel. Die + * [VisualTransformation] fügt Trennzeichen ausschließlich für die DARSTELLUNG ein, ohne den + * zugrunde liegenden Text zu verändern. Dadurch bleibt die Persistenzschicht vollständig + * unabhängig von der UI-Darstellung: Repository/DAO sehen nur die rohe Ziffernfolge + * (Telefonnummern) bzw. eine daraus geparste LocalDate (Geburtsdatum). + * + * [groupSizes] beschreibt die Länge jeder Zifferngruppe von links nach rechts (z. B. [2,2,4] + * für TT.MM.JJJJ). Ziffern, die über die Summe aller Gruppen hinausgehen, werden ohne weitere + * Gruppierung angehängt (z. B. [5] für Telefonnummern: nur nach der fünften Ziffer ein + * Leerzeichen, danach durchgehend). + */ +class DigitGroupVisualTransformation( + private val groupSizes: List, + private val separator: Char, +) : VisualTransformation { + + override fun filter(text: AnnotatedString): TransformedText { + val digits = text.text + val formatted = StringBuilder() + // separatorsBeforeDigit[i] = Anzahl der Trennzeichen, die in der formatierten Darstellung + // VOR der i-ten Ziffer stehen - wird für die Cursor-Rückrechnung (OffsetMapping) benötigt. + val separatorsBeforeDigit = IntArray(digits.length + 1) + + var groupIndex = 0 + var posInGroup = 0 + for (i in digits.indices) { + val currentGroupSize = groupSizes.getOrNull(groupIndex) + if (currentGroupSize != null && posInGroup == currentGroupSize) { + formatted.append(separator) + groupIndex++ + posInGroup = 0 + } + separatorsBeforeDigit[i] = formatted.count { it == separator } + formatted.append(digits[i]) + posInGroup++ + } + separatorsBeforeDigit[digits.length] = formatted.count { it == separator } + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val clamped = offset.coerceIn(0, digits.length) + return clamped + separatorsBeforeDigit[clamped] + } + + override fun transformedToOriginal(offset: Int): Int { + val clamped = offset.coerceIn(0, formatted.length) + var digitCount = 0 + for (i in 0 until clamped) { + if (formatted[i] != separator) digitCount++ + } + return digitCount + } + } + + return TransformedText(AnnotatedString(formatted.toString()), offsetMapping) + } +} + +/** Filtert eine Roheingabe auf reine Ziffern und begrenzt sie auf [maxDigits] Zeichen. */ +fun filterDigits(input: String, maxDigits: Int): String = + input.filter { it.isDigit() }.take(maxDigits) + +/** Gruppierung TT.MM.JJJJ, maximal 8 Ziffern. */ +val DATE_GROUP_SIZES = listOf(2, 2, 4) +const val DATE_MAX_DIGITS = 8 + +/** Gruppierung: erste 5 Ziffern, dann ein Leerzeichen, danach ungruppiert. */ +val PHONE_GROUP_SIZES = listOf(5) +const val PHONE_MAX_DIGITS = 15 // großzügige, defensive Obergrenze (E.164-Maximallänge) + +const val POSTAL_CODE_MAX_DIGITS = 5 // deutsche PLZ + +/** + * Wandelt eine 8-stellige Ziffernfolge im Format TTMMJJJJ in ein [LocalDate] um, oder liefert + * `null`, wenn die Länge nicht stimmt oder das Datum ungültig ist (z. B. "31022000" für den + * nicht existierenden 31. Februar). `LocalDate.of(...)` wirft in diesem Fall eine + * `DateTimeException`, die hier zu `null` statt einer stillschweigenden Fehlerkorrektur führt. + */ +fun germanDateDigitsToLocalDateOrNull(digits: String): LocalDate? { + if (digits.length != DATE_MAX_DIGITS) return null + return try { + val day = digits.substring(0, 2).toInt() + val month = digits.substring(2, 4).toInt() + val year = digits.substring(4, 8).toInt() + LocalDate.of(year, month, day) + } catch (e: Exception) { + null + } +} + +/** Kehrfunktion zu [germanDateDigitsToLocalDateOrNull]: liefert die TTMMJJJJ-Ziffernfolge. */ +fun LocalDate.toGermanDateDigits(): String = + "%02d%02d%04d".format(dayOfMonth, monthValue, year) diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index ee370a7..d36d496 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 @@ -111,7 +111,7 @@ fun DocumentationEditScreen( modifier = Modifier.fillMaxSize().padding(padding), block = currentBlock, onCheckedChanged = viewModel::onCheckedChanged, - onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) }, + onFreeTextChanged = viewModel::onFreeTextChanged, canGoBack = uiState.currentBlockIndex > 0, canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1, onPrevious = viewModel::onPreviousBlock, @@ -128,7 +128,7 @@ private fun DocumentationBlockContent( modifier: Modifier = Modifier, block: DocumentationBlock, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, - onFreeTextChanged: (String) -> Unit, + onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, canGoBack: Boolean, canGoNext: Boolean, onPrevious: () -> Unit, @@ -142,15 +142,17 @@ private fun DocumentationBlockContent( ) { Text(block.title, style = MaterialTheme.typography.headlineSmall) - block.items.forEach { item -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) } - - OutlinedTextField( - value = block.freeText, - onValueChange = onFreeTextChanged, - label = { Text(stringResource(R.string.documentation_edit_free_text_label)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - ) + block.items.forEach { item -> + if (item.isFreeText) { + FreeTextCheckItemRow( + item = item, + onCheckedChanged = onCheckedChanged, + onFreeTextChanged = onFreeTextChanged, + ) + } else { + CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) + } + } Row( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), @@ -179,3 +181,34 @@ private fun CheckItemRow( Text(item.label) } } + +/** + * Die Freitext-Option eines Blocks (Änderungswunsch: Freitext als weitere auswählbare Option + * statt eigenes Feld am Block). Das Textfeld ist deaktiviert, solange die zugehörige Checkbox + * nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option auch als + * "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`). + */ +@Composable +private fun FreeTextCheckItemRow( + item: CheckItem, + onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, + onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, +) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = item.checked, + onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) }, + ) + Text(item.label) + } + OutlinedTextField( + value = item.freeTextValue, + onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) }, + enabled = item.checked, + label = { Text(stringResource(R.string.documentation_edit_free_text_label)) }, + modifier = Modifier.fillMaxWidth().padding(start = 40.dp), + minLines = 2, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index f62e5be..b18bc75 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 @@ -51,7 +51,8 @@ class DocumentationEditViewModel( private val freeTextChanges = MutableSharedFlow>(extraBufferCapacity = 64) - /** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */ + /** Letzter noch nicht persistierter Freitext-Wert je CheckItemState-ID (Freitext-Option + * eines Blocks), für [flushPendingChanges]. */ private val pendingFreeText = mutableMapOf() init { @@ -74,29 +75,37 @@ class DocumentationEditViewModel( @OptIn(FlowPreview::class) private fun observeFreeTextChanges() { viewModelScope.launch { - freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) -> - persistFreeText(blockStateId, text) + freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (checkItemStateId, text) -> + persistFreeText(checkItemStateId, text) } } } - private suspend fun persistFreeText(blockStateId: String, text: String) { - repository.updateFreeText(blockStateId, text) + private suspend fun persistFreeText(checkItemStateId: String, text: String) { + repository.updateFreeText(checkItemStateId, text) // Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist. - if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId) + if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId) } - fun onFreeTextChanged(blockStateId: String, text: String) { - updateLocalFreeText(blockStateId, text) - pendingFreeText[blockStateId] = text - freeTextChanges.tryEmit(blockStateId to text) + /** + * [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText = + * true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine + * Checkbox-Option kein eigenes Feld am Block mehr. + */ + fun onFreeTextChanged(checkItemStateId: String, text: String) { + updateLocalFreeText(checkItemStateId, text) + pendingFreeText[checkItemStateId] = text + freeTextChanges.tryEmit(checkItemStateId to text) } - private fun updateLocalFreeText(blockStateId: String, text: String) { + private fun updateLocalFreeText(checkItemStateId: String, text: String) { _uiState.update { state -> val documentation = state.documentation ?: return@update state val updatedBlocks = documentation.blocks.map { block -> - if (block.blockStateId == blockStateId) block.copy(freeText = text) else block + val updatedItems = block.items.map { item -> + if (item.checkItemStateId == checkItemStateId) item.copy(freeTextValue = text) else item + } + block.copy(items = updatedItems) } state.copy(documentation = documentation.copy(blocks = updatedBlocks)) } @@ -142,7 +151,7 @@ class DocumentationEditViewModel( val toFlush = pendingFreeText.toMap() pendingFreeText.clear() viewModelScope.launch { - toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) } + toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) } } } diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt index 727711a..9ef8370 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -15,6 +16,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -31,15 +33,18 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import de.medapp.wunddoku.R +import de.medapp.wunddoku.ui.components.format.DATE_GROUP_SIZES +import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation +import de.medapp.wunddoku.ui.components.format.PHONE_GROUP_SIZES /** - * Formular für Schritt 1 einer neuen Wunddokumentation (Aufgabenstellung Abschnitt 7) UND für - * das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über - * [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm]. + * Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von + * Patienten-Stammdaten – unterschieden über [PatientFormViewModel.startsNewDocumentation]. * - * Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT) - * erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle - * Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst. + * Geburtsdatum und Telefonnummern nutzen dieselbe wiederverwendbare + * [DigitGroupVisualTransformation] (ui/components/format/FormattedInput.kt): Der zugrunde + * liegende Zustand im ViewModel bleibt jeweils eine reine Ziffernfolge, die Trennzeichen + * (Punkte bzw. Leerzeichen) werden ausschließlich für die Darstellung ergänzt. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -108,42 +113,62 @@ fun PatientFormScreen( modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( - value = uiState.birthDateText, - onValueChange = viewModel::onBirthDateTextChanged, + value = uiState.birthDateDigits, + onValueChange = viewModel::onBirthDateDigitsChanged, label = { Text(stringResource(R.string.patient_form_birth_date)) }, - placeholder = { Text("JJJJ-MM-TT") }, + placeholder = { Text("TT.MM.JJJJ") }, + visualTransformation = DigitGroupVisualTransformation(DATE_GROUP_SIZES, '.'), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.street, + onValueChange = viewModel::onStreetChanged, + label = { Text(stringResource(R.string.patient_form_street)) }, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( - value = uiState.address, - onValueChange = viewModel::onAddressChanged, - label = { Text(stringResource(R.string.patient_form_address)) }, + value = uiState.postalCode, + onValueChange = viewModel::onPostalCodeChanged, + label = { Text(stringResource(R.string.patient_form_postal_code)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = uiState.city, + onValueChange = viewModel::onCityChanged, + label = { Text(stringResource(R.string.patient_form_city)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( value = uiState.phone1, onValueChange = viewModel::onPhone1Changed, label = { Text(stringResource(R.string.patient_form_phone1)) }, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone), + visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = uiState.phone2, onValueChange = viewModel::onPhone2Changed, label = { Text(stringResource(R.string.patient_form_phone2)) }, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone), + visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = uiState.phone3, onValueChange = viewModel::onPhone3Changed, label = { Text(stringResource(R.string.patient_form_phone3)) }, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone), + visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), modifier = Modifier.fillMaxWidth(), ) uiState.validationError?.let { error -> - Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error) + Text(error, color = MaterialTheme.colorScheme.error) } Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt index 8f27c1d..fc15f18 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt @@ -10,6 +10,12 @@ import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.domain.model.Patient +import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS +import de.medapp.wunddoku.ui.components.format.PHONE_MAX_DIGITS +import de.medapp.wunddoku.ui.components.format.POSTAL_CODE_MAX_DIGITS +import de.medapp.wunddoku.ui.components.format.filterDigits +import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull +import de.medapp.wunddoku.ui.components.format.toGermanDateDigits import de.medapp.wunddoku.ui.navigation.Screen import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,16 +23,22 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.Instant -import java.time.LocalDate data class PatientFormUiState( val isEditing: Boolean = false, val isLoading: Boolean = true, val firstName: String = "", val lastName: String = "", - /** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */ - val birthDateText: String = "", - val address: String = "", + /** Rohe Ziffernfolge (max. 8 Zeichen, TTMMJJJJ) - die Formatierung TT.MM.JJJJ erfolgt + * ausschließlich über eine VisualTransformation in der Compose-UI, s. + * ui/components/format/FormattedInput.kt. */ + val birthDateDigits: String = "", + val street: String = "", + /** Rohe Ziffernfolge, max. 5 Zeichen (deutsche PLZ). */ + val postalCode: String = "", + val city: String = "", + /** Rohe Ziffernfolgen (führende Nullen bleiben erhalten, da als String geführt) - die + * Leerzeichen-Formatierung erfolgt ausschließlich über eine VisualTransformation in der UI. */ val phone1: String = "", val phone2: String = "", val phone3: String = "", @@ -40,8 +52,7 @@ data class PatientFormUiState( ) /** - * Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab - * (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6). + * Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab. */ class PatientFormViewModel( private val patientId: String?, @@ -63,8 +74,10 @@ class PatientFormViewModel( isLoading = false, firstName = patient.firstName, lastName = patient.lastName, - birthDateText = patient.birthDate.toString(), - address = patient.address, + birthDateDigits = patient.birthDate.toGermanDateDigits(), + street = patient.street, + postalCode = patient.postalCode, + city = patient.city, phone1 = patient.phone1, phone2 = patient.phone2.orEmpty(), phone3 = patient.phone3.orEmpty(), @@ -81,11 +94,25 @@ class PatientFormViewModel( fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) } fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) } - fun onBirthDateTextChanged(value: String) = _uiState.update { it.copy(birthDateText = value) } - fun onAddressChanged(value: String) = _uiState.update { it.copy(address = value) } - fun onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) } - fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) } - fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) } + + fun onBirthDateDigitsChanged(raw: String) = + _uiState.update { it.copy(birthDateDigits = filterDigits(raw, DATE_MAX_DIGITS)) } + + fun onStreetChanged(value: String) = _uiState.update { it.copy(street = value) } + + fun onPostalCodeChanged(raw: String) = + _uiState.update { it.copy(postalCode = filterDigits(raw, POSTAL_CODE_MAX_DIGITS)) } + + fun onCityChanged(value: String) = _uiState.update { it.copy(city = value) } + + fun onPhone1Changed(raw: String) = + _uiState.update { it.copy(phone1 = filterDigits(raw, PHONE_MAX_DIGITS)) } + + fun onPhone2Changed(raw: String) = + _uiState.update { it.copy(phone2 = filterDigits(raw, PHONE_MAX_DIGITS)) } + + fun onPhone3Changed(raw: String) = + _uiState.update { it.copy(phone3 = filterDigits(raw, PHONE_MAX_DIGITS)) } fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) } @@ -105,18 +132,21 @@ class PatientFormViewModel( fun save(skipDuplicateCheck: Boolean = false) { val state = _uiState.value - val birthDate = try { - LocalDate.parse(state.birthDateText.trim()) - } catch (e: Exception) { - _uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") } + val birthDate = germanDateDigitsToLocalDateOrNull(state.birthDateDigits) + if (birthDate == null) { + _uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum eingeben (TT.MM.JJJJ).") } return } if (state.firstName.isBlank() || state.lastName.isBlank()) { _uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") } return } - if (state.address.isBlank() || state.phone1.isBlank()) { - _uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") } + if (state.street.isBlank() || state.postalCode.isBlank() || state.city.isBlank()) { + _uiState.update { it.copy(validationError = "Straße, PLZ und Stadt dürfen nicht leer sein.") } + return + } + if (state.phone1.isBlank()) { + _uiState.update { it.copy(validationError = "Telefonnummer 1 darf nicht leer sein.") } return } @@ -134,10 +164,12 @@ class PatientFormViewModel( firstName = state.firstName.trim(), lastName = state.lastName.trim(), birthDate = birthDate, - address = state.address.trim(), - phone1 = state.phone1.trim(), - phone2 = state.phone2.trim().ifBlank { null }, - phone3 = state.phone3.trim().ifBlank { null }, + street = state.street.trim(), + postalCode = state.postalCode.trim(), + city = state.city.trim(), + phone1 = state.phone1, + phone2 = state.phone2.ifBlank { null }, + phone3 = state.phone3.ifBlank { null }, createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt updatedAt = Instant.EPOCH, ) @@ -150,8 +182,8 @@ class PatientFormViewModel( /** * Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu * gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine - * neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder - * nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste). + * neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder nur + * signalisieren, dass gespeichert wurde (zurück zur Patientenliste). */ private suspend fun finish(resolvedPatientId: String) { if (startsNewDocumentation) { @@ -166,10 +198,6 @@ class PatientFormViewModel( fun factory() = viewModelFactory { initializer { val container = wunddokuApplication().container - // createSavedStateHandle() ist die von Google empfohlene Art, innerhalb eines - // viewModelFactory-Initializers an das SavedStateHandle zu kommen; toRoute() - // dekodiert daraus direkt die typsichere Navigations-Route (Screen.PatientForm), - // statt Argumente manuell als lose Strings auszulesen. val savedStateHandle = createSavedStateHandle() val route = savedStateHandle.toRoute() PatientFormViewModel( diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt index 65617f9..9849378 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt @@ -163,7 +163,7 @@ private fun PatientListItemCard( "Geburtsdatum: ${item.birthDate.format(dateFormatter)}", style = MaterialTheme.typography.bodySmall, ) - Text(item.address, style = MaterialTheme.typography.bodySmall) + Text(item.displayAddress, style = MaterialTheme.typography.bodySmall) val lastDoc = item.lastDocumentationAt if (lastDoc != null) { Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 106d7de..cbeda3e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,7 +24,9 @@ Vorname Nachname Geburtsdatum - Adresse + Straße & Hausnummer + PLZ + Stadt Telefonnummer 1 Telefonnummer 2 (optional) Telefonnummer 3 (optional) diff --git a/settings.gradle.kts b/settings.gradle.kts index 52fe9f4..5997342 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,9 +5,6 @@ pluginManagement { gradlePluginPortal() } } -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" -} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) From 25469e745a958afc1c4fe59b3a95858631dd5b5c Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Sat, 22 Aug 2026 11:10:59 +0200 Subject: [PATCH 2/2] FUNC: Neue Wunddoku mit Patienten durchsuchen --- .../ui/patients/form/PatientFormScreen.kt | 92 +++++++++++++++++++ .../ui/patients/form/PatientFormViewModel.kt | 72 ++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt index 9ef8370..69f3d74 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt @@ -1,7 +1,9 @@ package de.medapp.wunddoku.ui.patients.form +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -10,10 +12,13 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -33,9 +38,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import de.medapp.wunddoku.R +import de.medapp.wunddoku.domain.model.PatientListItem import de.medapp.wunddoku.ui.components.format.DATE_GROUP_SIZES import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation import de.medapp.wunddoku.ui.components.format.PHONE_GROUP_SIZES +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) /** * Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von @@ -100,6 +110,10 @@ fun PatientFormScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (!uiState.isEditing) { + ExistingPatientSearchSection(uiState = uiState, viewModel = viewModel) + } + OutlinedTextField( value = uiState.firstName, onValueChange = viewModel::onFirstNameChanged, @@ -202,3 +216,81 @@ fun PatientFormScreen( ) } } + +/** + * "Bestehenden Patienten auswählen" - Suchfeld über der Stammdateneingabe (nur beim Anlegen + * eines neuen Patienten sichtbar, nicht beim Bearbeiten eines bereits feststehenden). Wählt der + * Nutzer einen Treffer aus, befüllt [PatientFormViewModel.onExistingPatientSelected] das + * restliche Formular automatisch; die Auswahl selbst wird als Chip mit "Aufheben"-Möglichkeit + * dargestellt, solange sie aktiv ist. + */ +@Composable +private fun ExistingPatientSearchSection( + uiState: PatientFormUiState, + viewModel: PatientFormViewModel, +) { + val selectedId = uiState.selectedExistingPatientId + if (selectedId != null) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text("Bestehender Patient ausgewählt", style = MaterialTheme.typography.labelMedium) + Text("${uiState.firstName} ${uiState.lastName}", style = MaterialTheme.typography.bodyLarge) + } + IconButton(onClick = viewModel::clearSelectedExistingPatient) { + Icon(Icons.Filled.Close, contentDescription = "Auswahl aufheben") + } + } + } + return + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + value = uiState.patientSearchQuery, + onValueChange = viewModel::onPatientSearchQueryChanged, + label = { Text("Bestehenden Patienten auswählen") }, + placeholder = { Text("Name oder Geburtsdatum suchen") }, + modifier = Modifier.fillMaxWidth(), + ) + + if (uiState.patientSearchQuery.isNotBlank()) { + if (uiState.patientSearchResults.isEmpty()) { + Text( + "Keine passenden Patienten gefunden.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } else { + Card(modifier = Modifier.fillMaxWidth()) { + Column { + uiState.patientSearchResults.forEachIndexed { index, item -> + if (index > 0) HorizontalDivider() + PatientSearchResultRow(item = item, onClick = { viewModel.onExistingPatientSelected(item.id) }) + } + } + } + } + } + } +} + +@Composable +private fun PatientSearchResultRow(item: PatientListItem, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(12.dp), + ) { + Text(item.fullName, style = MaterialTheme.typography.bodyLarge) + Text( + "${item.birthDate.format(dateFormatter)} · ${item.displayAddress}", + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt index fc15f18..d8e3d4f 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt @@ -10,6 +10,7 @@ import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.domain.model.Patient +import de.medapp.wunddoku.domain.model.PatientListItem import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.PHONE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.POSTAL_CODE_MAX_DIGITS @@ -17,9 +18,12 @@ import de.medapp.wunddoku.ui.components.format.filterDigits import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull import de.medapp.wunddoku.ui.components.format.toGermanDateDigits import de.medapp.wunddoku.ui.navigation.Screen +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.Instant @@ -45,6 +49,16 @@ data class PatientFormUiState( val validationError: String? = null, /** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */ val duplicateWarning: Patient? = null, + /** Suchfeld "bestehenden Patienten auswählen" - nur relevant, solange isEditing = false. */ + val patientSearchQuery: String = "", + val patientSearchResults: List = emptyList(), + /** + * != null, sobald der Nutzer über die Suche einen bestehenden Patienten ausgewählt hat. + * Das Formular ist dann mit dessen Daten vorbefüllt; Speichern aktualisiert diesen + * Patienten, statt einen neuen anzulegen, und überspringt die separate + * Duplikat-Erkennung (der Nutzer hat den Patienten bereits bewusst gewählt). + */ + val selectedExistingPatientId: String? = null, /** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */ val patientSaved: Boolean = false, /** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */ @@ -64,7 +78,12 @@ class PatientFormViewModel( private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null)) val uiState: StateFlow = _uiState.asStateFlow() + /** Getrennt vom UiState-Textfeld geführt, damit flatMapLatest die Suche sauber debouncen/ + * abbrechen kann, ohne den restlichen UiState anzufassen. */ + private val patientSearchQuery = MutableStateFlow("") + init { + observePatientSearch() if (patientId != null) { viewModelScope.launch { val patient = repository.getById(patientId) @@ -114,6 +133,54 @@ class PatientFormViewModel( fun onPhone3Changed(raw: String) = _uiState.update { it.copy(phone3 = filterDigits(raw, PHONE_MAX_DIGITS)) } + @OptIn(ExperimentalCoroutinesApi::class) + private fun observePatientSearch() { + viewModelScope.launch { + patientSearchQuery + .flatMapLatest { query -> if (query.isBlank()) flowOf(emptyList()) else repository.search(query) } + .collect { results -> _uiState.update { it.copy(patientSearchResults = results) } } + } + } + + fun onPatientSearchQueryChanged(query: String) { + patientSearchQuery.value = query + _uiState.update { it.copy(patientSearchQuery = query) } + } + + /** + * Der Nutzer wählt im Suchfeld "bestehenden Patienten auswählen" einen Treffer aus - das + * restliche Formular wird automatisch mit dessen aktuellen Stammdaten befüllt. + */ + fun onExistingPatientSelected(patientId: String) { + viewModelScope.launch { + val patient = repository.getById(patientId) ?: return@launch + _uiState.update { + it.copy( + selectedExistingPatientId = patient.id, + firstName = patient.firstName, + lastName = patient.lastName, + birthDateDigits = patient.birthDate.toGermanDateDigits(), + street = patient.street, + postalCode = patient.postalCode, + city = patient.city, + phone1 = patient.phone1, + phone2 = patient.phone2.orEmpty(), + phone3 = patient.phone3.orEmpty(), + patientSearchQuery = "", + patientSearchResults = emptyList(), + validationError = null, + ) + } + patientSearchQuery.value = "" + } + } + + /** Hebt eine per Suche getroffene Auswahl wieder auf - die Formularfelder bleiben editierbar + * erhalten (Speichern legt dann einen NEUEN Patienten mit den aktuell eingetragenen Werten an). */ + fun clearSelectedExistingPatient() { + _uiState.update { it.copy(selectedExistingPatientId = null) } + } + fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) } /** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */ @@ -151,7 +218,8 @@ class PatientFormViewModel( } viewModelScope.launch { - if (!state.isEditing && !skipDuplicateCheck) { + val skipBecauseExplicitlySelected = state.selectedExistingPatientId != null + if (!state.isEditing && !skipDuplicateCheck && !skipBecauseExplicitlySelected) { val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate) if (existing != null) { _uiState.update { it.copy(duplicateWarning = existing, validationError = null) } @@ -160,7 +228,7 @@ class PatientFormViewModel( } val patient = Patient( - id = patientId ?: repository.newId(), + id = patientId ?: state.selectedExistingPatientId ?: repository.newId(), firstName = state.firstName.trim(), lastName = state.lastName.trim(), birthDate = birthDate,