Beta 1.0 Release #11

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