Formularformatierung

This commit is contained in:
2026-08-21 09:38:41 +02:00
parent b6e7ce999b
commit 34de5b5c5a
32 changed files with 1148 additions and 203 deletions
@@ -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
* übernimmt DocumentationRepository). Kein `update` für ganze Entities nötig, da nur
* `freeText`/`updatedAt` sich nach dem Erstellen je ändern (Autosave) – ein gezieltes
* Teil-Update ist hier klarer als ein vollständiges Entity-Update.
* übernimmt DocumentationRepository).
*/
@Dao
interface BlockStateDao {
@@ -22,7 +20,4 @@ interface BlockStateDao {
@Query("SELECT * FROM block_states WHERE documentationId = :documentationId ORDER BY blockOrder")
suspend fun getForDocumentation(documentationId: String): List<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")
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 de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient
import kotlinx.coroutines.flow.Flow
@Dao
@@ -27,15 +26,22 @@ interface DocumentationDao {
suspend fun getById(id: String): DocumentationEntity?
/**
* Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen mit
* Patientenbezug, ohne die vollständigen Blöcke zu laden – bewusst schlank für Performance
* bei größeren Datenmengen.
* Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen.
* Liefert direkt [DocumentationEntity] statt einer Relation mit Patient - Name/Adresse für
* die Anzeige stehen bereits als Snapshot auf der Dokumentation selbst (siehe
* DocumentationEntity.patientFirstNameSnapshot etc.), ein JOIN mit der Patient-Tabelle ist
* für die reine Listenanzeige nicht mehr nötig.
*/
@Transaction
@Query("SELECT * FROM documentations ORDER BY createdAt DESC")
fun observeAllWithPatient(): Flow<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(
"""
SELECT d.* FROM documentations d
@@ -46,7 +52,7 @@ interface DocumentationDao {
ORDER BY d.createdAt DESC
"""
)
fun search(query: String): Flow<List<DocumentationWithPatient>>
fun search(query: String): Flow<List<DocumentationEntity>>
@Transaction
@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
* app/schemas/ ablegt (im Gradle-Skript über `room { schemaDirectory(...) }` zu konfigurieren,
* s. Kommentar unten) – das ist die Grundlage für spätere, nachvollziehbare Migrationen.
* app/schemas/ ablegt – Grundlage für nachvollziehbare Migrationen.
*
* Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung -
* Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von
* CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf
* DocumentationEntity.
*
* `fallbackToDestructiveMigration()`: Da die App sich noch vor dem produktiven Einsatz befindet
* und keine echten Patientendaten migriert werden müssen, wird hier bewusst auf eine
* geschriebene `Migration`-Klasse verzichtet - die lokale Datenbank wird bei einem
* Versionssprung einfach neu angelegt (bestehende lokale Testdaten auf dem Gerät/Emulator gehen
* dabei verloren). Das ist eine bewusste, vorübergehende Entwicklungsentscheidung: VOR dem
* ersten produktiven Rollout muss dies durch eine echte `Migration(1, 2) { ... }` ersetzt
* werden, die reale Patientendaten erhält (insbesondere: die bisherige `address`-Spalte müsste
* dann sinnvoll in street/postalCode/city überführt werden, z. B. gesamten Altwert nach
* `street` und postalCode/city leer lassen, da eine automatische Text-Zerlegung einer
* Freitext-Adresse nicht zuverlässig möglich ist).
*/
@Database(
entities = [
@@ -33,7 +48,7 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
PhotoEntity::class,
SignatureEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
)
@TypeConverters(DocumentationStatusConverter::class)
@@ -58,7 +73,9 @@ abstract class AppDatabase : RoomDatabase() {
context.applicationContext,
AppDatabase::class.java,
DATABASE_NAME,
).build().also { instance = it }
)
.fallbackToDestructiveMigration() // s. Klassenkommentar: vorübergehend, vor Produktivbetrieb ersetzen
.build().also { instance = it }
}
}
}
@@ -15,9 +15,13 @@ import androidx.room.PrimaryKey
* Foreign Key: Der Katalog ist keine Datenbanktabelle, sondern Kotlin-Code.
*
* [titleSnapshot]/[blockOrder] speichern den zum Erstellungszeitpunkt der Dokumentation
* gültigen Titel bzw. die gültige Reihenfolge (Architektur-Dokument Abschnitt 2.4) – das
* stellt sicher, dass eine später geöffnete, historische Dokumentation unverändert korrekt
* dargestellt wird, selbst wenn sich der Katalog danach ändert.
* gültigen Titel bzw. die gültige Reihenfolge - das stellt sicher, dass eine später geöffnete,
* historische Dokumentation unverändert korrekt dargestellt wird, selbst wenn sich der Katalog
* danach ändert.
*
* Kein `freeText`-Feld mehr: Der Freitext eines Blocks wird seit der Umstellung auf eine
* Checkbox-Option als CheckItemStateEntity mit `isFreeText = true` geführt (siehe dort), nicht
* mehr als eigenes Feld am Block.
*/
@Entity(
tableName = "block_states",
@@ -40,6 +44,5 @@ data class BlockStateEntity(
val blockDefinitionId: String,
val titleSnapshot: String,
val blockOrder: Int,
val freeText: String,
val updatedAt: Long,
)
@@ -10,6 +10,11 @@ import androidx.room.PrimaryKey
*
* [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche
* Begründung, auf Checkbox-Ebene.
*
* [isFreeText]/[freeTextValue]: Jeder Block bekommt beim Erstellen automatisch genau eine
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
* Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer.
*/
@Entity(
tableName = "check_item_states",
@@ -33,4 +38,6 @@ data class CheckItemStateEntity(
val labelSnapshot: String,
val itemOrder: Int,
val checked: Boolean,
val isFreeText: Boolean = false,
val freeTextValue: String = "",
)
@@ -49,4 +49,13 @@ data class DocumentationEntity(
val continuedFromDocumentationId: String?,
val pdfLastGeneratedAt: Long?,
val catalogVersion: Int,
// Patienten-Stammdaten-Snapshot zum Erstellungszeitpunkt dieser Dokumentation - siehe
// ausführliche Begründung in domain/model/PatientStammdatenSnapshot.kt. Frisch befüllt bei
// createEmpty()/continueDocumentation() im DocumentationRepository, danach unveränderlich.
val patientFirstNameSnapshot: String,
val patientLastNameSnapshot: String,
val patientBirthDateSnapshot: String,
val patientStreetSnapshot: String,
val patientPostalCodeSnapshot: String,
val patientCitySnapshot: String,
)
@@ -13,6 +13,9 @@ import androidx.room.PrimaryKey
* TypeConverter auf LocalDate: Der String ist bereits das native SQLite-Format, direkt
* sortierbar und ohne Konverter lesbar. Der Mapper (PatientMapper) übernimmt die Umwandlung
* zu/von `java.time.LocalDate` beim Übergang zur Domain-Schicht.
* Die Adresse wird atomar gespeichert (street/postalCode/city) statt als einzelnes Freitextfeld
* - ermöglicht z. B. eine passende Tastatur für die PLZ-Eingabe und vermeidet, dass die
* Persistenzschicht auf unstrukturiertem Freitext basiert.
*/
@Entity(
tableName = "patients",
@@ -26,7 +29,9 @@ data class PatientEntity(
val firstName: String,
val lastName: String,
val birthDate: String,
val address: String,
val street: String,
val postalCode: String,
val city: String,
val phone1: String,
val phone2: String?,
val phone3: String?,
@@ -5,7 +5,6 @@ import androidx.room.Relation
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
@@ -56,16 +55,3 @@ data class DocumentationWithDetails(
)
val signatures: List<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
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.relations.BlockStateWithItems
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationSummary
import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot
import java.time.Instant
import java.time.LocalDate
/**
* Wandelt zwischen den Room-Relations und den Domain-Modellen [Documentation]/
* [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern
* ([BlockStateEntity.titleSnapshot], [CheckItemStateEntity.labelSnapshot]) – hier findet KEINE
* Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt (Architektur-
* Dokument Abschnitt 2.4).
* Wandelt zwischen den Room-Relations/Entities und den Domain-Modellen [Documentation]/
* [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern - hier
* findet KEINE Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt.
*/
fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
id = documentation.id,
patientId = documentation.patientId,
patientSnapshot = documentation.toPatientSnapshot(),
status = documentation.status,
createdAt = Instant.ofEpochMilli(documentation.createdAt),
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
@@ -56,7 +56,6 @@ fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
definitionId = blockState.blockDefinitionId,
title = blockState.titleSnapshot,
order = blockState.blockOrder,
freeText = blockState.freeText,
items = checkItems
.sortedBy { it.itemOrder }
.map { it.toDomain() },
@@ -68,13 +67,29 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
label = labelSnapshot,
order = itemOrder,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
)
fun DocumentationWithPatient.toSummary(): DocumentationSummary = DocumentationSummary(
id = documentation.id,
patientId = documentation.patientId,
patientFullName = "${patient.firstName} ${patient.lastName}",
status = documentation.status,
createdAt = Instant.ofEpochMilli(documentation.createdAt),
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
/**
* Liefert direkt aus [DocumentationEntity] eine [DocumentationSummary] für Listenansichten -
* ohne Join/Relation mit der Patient-Tabelle, da Name für die Anzeige bereits als Snapshot auf
* der Dokumentation selbst liegt (siehe PatientStammdatenSnapshot-Dokumentation).
*/
fun DocumentationEntity.toSummary(): DocumentationSummary = DocumentationSummary(
id = id,
patientId = patientId,
patientFullName = "$patientFirstNameSnapshot $patientLastNameSnapshot",
status = status,
createdAt = Instant.ofEpochMilli(createdAt),
updatedAt = Instant.ofEpochMilli(updatedAt),
)
fun DocumentationEntity.toPatientSnapshot(): PatientStammdatenSnapshot = PatientStammdatenSnapshot(
firstName = patientFirstNameSnapshot,
lastName = patientLastNameSnapshot,
birthDate = LocalDate.parse(patientBirthDateSnapshot),
street = patientStreetSnapshot,
postalCode = patientPostalCodeSnapshot,
city = patientCitySnapshot,
)
@@ -10,16 +10,16 @@ import java.time.LocalDate
*
* Kotlin-Hinweis: Dies sind sogenannte Extension Functions (`fun PatientEntity.toDomain()`)
* – sie erweitern eine bestehende Klasse um eine Methode, ohne die Klasse selbst zu verändern
* oder von ihr zu erben. Der Aufruf sieht dadurch wie eine normale Methode aus:
* `patientEntity.toDomain()`. Praktisch, um Mapping-Logik nahe an den beteiligten Typen zu
* halten, ohne eine eigene Mapper-Klasse mit Konstruktor-Injection zu benötigen.
* oder von ihr zu erben.
*/
fun PatientEntity.toDomain(): Patient = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = LocalDate.parse(birthDate),
address = address,
street = street,
postalCode = postalCode,
city = city,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
@@ -32,7 +32,9 @@ fun Patient.toEntity(): PatientEntity = PatientEntity(
firstName = firstName,
lastName = lastName,
birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd)
address = address,
street = street,
postalCode = postalCode,
city = city,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
@@ -6,6 +6,7 @@ import androidx.room.withTransaction
import de.medapp.wunddoku.data.dao.BlockStateDao
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.database.AppDatabase
@@ -33,7 +34,10 @@ import java.util.UUID
* Koordiniert alle Lese-/Schreiboperationen rund um eine Wunddokumentation über mehrere DAOs
* hinweg. ViewModels sehen niemals mehr als dieses eine Repository für Dokumentations-bezogene
* Daten – Mehrfach-DAO-Koordination und Room-Transaktionen (`db.withTransaction`) sind
* ausschließlich hier gebündelt (Architektur-Dokument Abschnitt 5).
* ausschließlich hier gebündelt.
*
* [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein
* frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation).
*/
class DocumentationRepository(
private val documentationDao: DocumentationDao,
@@ -41,13 +45,14 @@ class DocumentationRepository(
private val checkItemStateDao: CheckItemStateDao,
private val photoDao: PhotoDao,
private val signatureDao: SignatureDao,
private val patientDao: PatientDao,
private val photoStorage: PhotoStorage,
private val signatureFileStorage: SignatureFileStorage,
private val db: AppDatabase,
) {
fun observeAll(): Flow<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>> =
documentationDao.search(query).map { list -> list.map { it.toSummary() } }
@@ -57,13 +62,15 @@ class DocumentationRepository(
/**
* Legt eine neue, leere Wunddokumentation für [patientId] an: eine [DocumentationEntity]
* plus je einen [BlockStateEntity] pro Katalog-Block (mit Label-/Titel-Snapshot aus dem
* *aktuellen* Katalogstand, Architektur-Dokument Abschnitt 2.4) und je einen
* [CheckItemStateEntity] pro Katalog-Checkbox. Läuft in einer einzigen Room-Transaktion,
* damit niemals eine halb angelegte Dokumentation (z. B. Documentation-Zeile ohne
* zugehörige Blöcke) entstehen kann.
* (inkl. frischem Patienten-Stammdaten-Snapshot aus der aktuellen Patient-Tabelle) plus je
* einen [BlockStateEntity] pro Katalog-Block (mit Titel-/Reihenfolge-Snapshot aus dem
* *aktuellen* Katalogstand) und je einen [CheckItemStateEntity] pro Katalog-Checkbox, PLUS
* eine zusätzliche Freitext-Option pro Block (isFreeText = true). Läuft in einer einzigen
* Room-Transaktion, damit niemals eine halb angelegte Dokumentation entstehen kann.
*/
suspend fun createEmpty(patientId: String): Documentation = db.withTransaction {
val patient = patientDao.getById(patientId)
?: error("Patient $patientId nicht gefunden")
val now = Instant.now().toEpochMilli()
val documentationId = UUID.randomUUID().toString()
@@ -77,6 +84,12 @@ class DocumentationRepository(
continuedFromDocumentationId = null,
pdfLastGeneratedAt = null,
catalogVersion = DocumentationCatalog.CATALOG_VERSION,
patientFirstNameSnapshot = patient.firstName,
patientLastNameSnapshot = patient.lastName,
patientBirthDateSnapshot = patient.birthDate,
patientStreetSnapshot = patient.street,
patientPostalCodeSnapshot = patient.postalCode,
patientCitySnapshot = patient.city,
)
)
@@ -86,19 +99,21 @@ class DocumentationRepository(
}
/**
* "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9, präzisiert in Architektur-
* Dokument Abschnitt 6):
* "Dokumentation fortsetzen":
* - Patient: gleiche patientId, kein neuer Patientendatensatz
* - BlockState/CheckItemState: vollständig von der Quelle kopiert, inkl. Snapshots und
* Reihenfolge der QUELLE (nicht neu aus dem aktuellen Katalog gezogen) – die fortgesetzte
* Dokumentation beginnt exakt auf dem inhaltlichen Stand des Vorgängers.
* - Patienten-Stammdaten-Snapshot: wird FRISCH aus dem *aktuellen* Patientendatensatz
* gebildet (nicht von der Quelldokumentation kopiert) - die neue, fortgesetzte
* Dokumentation soll den zum jetzigen Zeitpunkt aktuellen Stand des Patienten abbilden
* (z. B. nach einem Umzug), während die Quelldokumentation ihren eigenen, damaligen
* Snapshot unverändert behält.
* - BlockState/CheckItemState (Checkboxzustände, Freitexte, Snapshots, Reihenfolge):
* vollständig von der Quelle kopiert, inkl. Freitext-Option
* - createdAt/updatedAt: neu
* - status: IN_PROGRESS
* - continuedFromDocumentationId: ID der Quelle
* - Unterschriften: werden NICHT kopiert (eine Unterschrift bestätigt nur die konkrete
* Dokumentation, unter der sie geleistet wurde)
* - Fotos: über [copyPhotos] steuerbar, Standard `false` – fachlich noch nicht final
* entschieden (siehe Architektur-Dokument Abschnitt 6)
* - Unterschriften: werden NICHT kopiert
* - Fotos: über [copyPhotos] steuerbar, Standard `false` - fachlich noch nicht final
* entschieden
*/
suspend fun continueDocumentation(
sourceDocumentationId: String,
@@ -106,6 +121,8 @@ class DocumentationRepository(
): Documentation = db.withTransaction {
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
val patient = patientDao.getById(source.documentation.patientId)
?: error("Patient ${source.documentation.patientId} nicht gefunden")
val now = Instant.now().toEpochMilli()
val newDocumentationId = UUID.randomUUID().toString()
@@ -120,10 +137,16 @@ class DocumentationRepository(
continuedFromDocumentationId = sourceDocumentationId,
pdfLastGeneratedAt = null,
catalogVersion = source.documentation.catalogVersion,
patientFirstNameSnapshot = patient.firstName,
patientLastNameSnapshot = patient.lastName,
patientBirthDateSnapshot = patient.birthDate,
patientStreetSnapshot = patient.street,
patientPostalCodeSnapshot = patient.postalCode,
patientCitySnapshot = patient.city,
)
)
// Blöcke + Checkboxen 1:1 von der Quelle übernehmen (inkl. Snapshots/Reihenfolge)
// Blöcke + Checkboxen (inkl. Freitext-Option) 1:1 von der Quelle übernehmen
for (sourceBlock in source.blocks) {
val newBlockStateId = UUID.randomUUID().toString()
blockStateDao.insertAll(
@@ -134,7 +157,6 @@ class DocumentationRepository(
blockDefinitionId = sourceBlock.blockState.blockDefinitionId,
titleSnapshot = sourceBlock.blockState.titleSnapshot,
blockOrder = sourceBlock.blockState.blockOrder,
freeText = sourceBlock.blockState.freeText,
updatedAt = now,
)
)
@@ -148,6 +170,8 @@ class DocumentationRepository(
labelSnapshot = item.labelSnapshot,
itemOrder = item.itemOrder,
checked = item.checked,
isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue,
)
}
)
@@ -174,25 +198,31 @@ class DocumentationRepository(
}
/**
* Komfort-Methode für die Patientenliste (Aufgabenstellung Abschnitt 6, Aktion
* "Behandlung/Dokumentation fortsetzen"): ermittelt die zuletzt erstellte Dokumentation
* eines Patienten und ruft darauf [continueDocumentation] auf. Liefert `null`, wenn der
* Patient noch keine Dokumentation besitzt (UI muss diesen Fall abfangen, s. Aufgabenstellung
* Abschnitt 20).
* Komfort-Methode für die Patientenliste ("Behandlung/Dokumentation fortsetzen"): ermittelt
* die zuletzt erstellte Dokumentation eines Patienten und ruft darauf
* [continueDocumentation] auf. Liefert `null`, wenn der Patient noch keine Dokumentation
* besitzt.
*/
suspend fun continueLatestForPatient(patientId: String): Documentation? {
val latestId = documentationDao.getLatestForPatient(patientId)?.documentation?.id ?: return null
return continueDocumentation(latestId)
}
suspend fun updateFreeText(blockStateId: String, text: String) {
blockStateDao.updateFreeText(blockStateId, text, Instant.now().toEpochMilli())
}
/** Speichert den Zustand einer normalen Checkbox ODER der Freitext-Checkbox eines Blocks. */
suspend fun setChecked(checkItemStateId: String, checked: Boolean) {
checkItemStateDao.setChecked(checkItemStateId, checked)
}
/**
* Speichert den Freitext der Freitext-Option eines Blocks. Der Freitext ist seit der
* Umstellung auf eine Checkbox-Option kein Feld am BlockState mehr, sondern liegt am
* jeweiligen CheckItemState (isFreeText = true) - [checkItemStateId] referenziert deshalb
* dieses CheckItem, nicht den Block.
*/
suspend fun updateFreeText(checkItemStateId: String, text: String) {
checkItemStateDao.updateFreeTextValue(checkItemStateId, text)
}
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
val fileName = photoStorage.copyFrom(sourceUri)
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
@@ -209,20 +239,15 @@ class DocumentationRepository(
}
/**
* Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Eine Room-Transaktion
* kann keine Dateisystemoperation einschließen. Reihenfolge: DB-Zeile zuerst, dann Datei –
* schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei zurück (unkritisch),
* statt eines DB-Eintrags, der auf eine nicht mehr existente Datei verweist (würde beim
* Anzeigen/PDF-Export zu einem sichtbaren Fehler führen). Siehe Architektur-Dokument
* Abschnitt 5.1.
* Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Reihenfolge DB-Zeile
* zuerst, dann Datei - schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei
* zurück (unkritisch), statt eines DB-Eintrags, der auf eine nicht mehr existente Datei
* verweist.
*/
suspend fun removePhoto(photoId: String) {
val photo = photoDao.getById(photoId) ?: return
photoDao.delete(photo)
runCatching { photoStorage.delete(photo.fileName) }
// Fehler beim Löschen der physischen Datei wird bewusst verschluckt: Die DB ist bereits
// konsistent, eine verwaiste Datei ist der unkritischere Fehlerfall. Kein Logging von
// Dateinamen mit Patientenbezug, s. Datenschutz-Abschnitt der Architektur.
}
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature {
@@ -248,9 +273,6 @@ class DocumentationRepository(
suspend fun delete(documentationId: String) {
val entity = documentationDao.getById(documentationId) ?: return
// Zugehörige Fotos/Signaturen zuerst physisch löschen (best effort), dann die
// Dokumentation selbst – CASCADE übernimmt das Entfernen der DB-Zeilen für
// BlockState/CheckItemState/Photo/Signature.
val photos = photoDao.getForDocumentation(documentationId)
val signatures = signatureDao.getForDocumentation(documentationId)
documentationDao.delete(entity)
@@ -258,6 +280,12 @@ class DocumentationRepository(
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } }
}
/**
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
* Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL
* und BlockDefinition.freeTextDefinitionId.
*/
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) {
val blockStateId = UUID.randomUUID().toString()
@@ -269,13 +297,12 @@ class DocumentationRepository(
blockDefinitionId = blockDef.id,
titleSnapshot = blockDef.title,
blockOrder = blockDef.order,
freeText = "",
updatedAt = now,
)
)
)
checkItemStateDao.insertAll(
blockDef.items.map { itemDef ->
val normalItems = blockDef.items.map { itemDef ->
CheckItemStateEntity(
id = UUID.randomUUID().toString(),
blockStateId = blockStateId,
@@ -283,9 +310,21 @@ class DocumentationRepository(
labelSnapshot = itemDef.label,
itemOrder = itemDef.order,
checked = false,
isFreeText = false,
freeTextValue = "",
)
}
val freeTextItem = CheckItemStateEntity(
id = UUID.randomUUID().toString(),
blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = blockDef.items.size + 1,
checked = false,
isFreeText = true,
freeTextValue = "",
)
checkItemStateDao.insertAll(normalItems + freeTextItem)
}
}
}
@@ -67,7 +67,9 @@ class PatientRepository(
id = patient.id,
fullName = "${patient.firstName} ${patient.lastName}",
birthDate = LocalDate.parse(patient.birthDate),
address = patient.address,
street = patient.street,
postalCode = patient.postalCode,
city = patient.city,
lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) },
)
}
@@ -39,6 +39,7 @@ class AppContainer(context: Context) {
checkItemStateDao = database.checkItemStateDao(),
photoDao = database.photoDao(),
signatureDao = database.signatureDao(),
patientDao = database.patientDao(),
photoStorage = photoStorage,
signatureFileStorage = signatureFileStorage,
db = database,
@@ -22,6 +22,14 @@ object DocumentationCatalog {
const val CATALOG_VERSION: Int = 1
/**
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch:
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral
* hier definiert, damit Repository (Erstellung/Fortsetzen) und ggf. der spätere PDF-Export
* dieselbe Bezeichnung verwenden.
*/
const val FREE_TEXT_LABEL: String = "Freitext"
val blocks: List<BlockDefinition> = listOf(
BlockDefinition(
id = "block_01",
@@ -64,7 +72,15 @@ data class BlockDefinition(
val title: String,
val order: Int,
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(
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
* aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Das stellt sicher, dass
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
* dargestellt werden. Siehe Architektur-Dokument Abschnitt 2.4.
* dargestellt werden.
*
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`,
* ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren
* PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu
* unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen.
*/
data class CheckItem(
val checkItemStateId: String,
@@ -15,4 +22,6 @@ data class CheckItem(
val label: String,
val order: Int,
val checked: Boolean,
val isFreeText: Boolean = false,
val freeTextValue: String = "",
)
@@ -16,6 +16,7 @@ import java.time.Instant
data class Documentation(
val id: String,
val patientId: String,
val patientSnapshot: PatientStammdatenSnapshot,
val status: DocumentationStatus,
val createdAt: Instant,
val updatedAt: Instant,
@@ -7,12 +7,12 @@ import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
* Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht
* aus einer Laufzeit-Auflösung des Katalogs.
*
* Kein eigenes `freeText`-Feld mehr: Der Freitext eines Blocks ist eine der Einträge in [items]
* (dasjenige CheckItem mit `isFreeText = true`) - siehe [CheckItem].
*
* [isEdited] wird bewusst NICHT in der Datenbank gespeichert, sondern hier als abgeleitete
* Eigenschaft berechnet ("computed property", kein eigenes Feld im Konstruktor). Das
* verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen Inhalt abweichen kann.
* [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert – sowohl die UI
* (Fortschrittsanzeige) als auch der spätere PDF-Export verwenden ausschließlich diese
* Eigenschaft. Siehe Architektur-Dokument Abschnitt 2.3c.
* Eigenschaft berechnet. Das verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen
* Inhalt abweichen kann. [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert.
*/
data class DocumentationBlock(
val blockStateId: String,
@@ -20,7 +20,6 @@ data class DocumentationBlock(
val title: String,
val order: Int,
val items: List<CheckItem>,
val freeText: String,
) {
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
* separate Suche/Sortierung nach Nachnamen (in Kliniken/Pflege üblich, z. B. "Nachname,
* Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen
* (z. B. Anrede + Nachname in Dokumenten), ohne den Namen nachträglich wieder aufteilen zu
* müssen.
* Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen.
*
* Die Adresse wird ebenso atomar erfasst ([street], [postalCode], [city]) statt als einzelnes
* Freitextfeld: ermöglicht z. B. spätere PLZ-basierte Auswertungen oder Validierung, ohne die
* Adresse nachträglich wieder zerlegen zu müssen.
*/
data class Patient(
val id: String,
val firstName: String,
val lastName: String,
val birthDate: LocalDate,
val address: String,
val street: String,
val postalCode: String,
val city: String,
val phone1: String,
val phone2: String?,
val phone3: String?,
@@ -26,4 +30,5 @@ data class Patient(
val updatedAt: Instant,
) {
val fullName: String get() = "$firstName $lastName"
val displayAddress: String get() = "$street, $postalCode $city"
}
@@ -4,14 +4,18 @@ import java.time.Instant
import java.time.LocalDate
/**
* Schlanke Projektion für die Patientenliste (Architektur-Dokument Abschnitt 6), inklusive
* Datum der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die
* Schlanke Projektion für die Patientenliste (Aufgabenstellung Abschnitt 6), inklusive Datum
* der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die
* Listen-Query nicht implizit an eine bestimmte Detaildarstellung gekoppelt ist.
*/
data class PatientListItem(
val id: String,
val fullName: String,
val birthDate: LocalDate,
val address: String,
val street: String,
val postalCode: String,
val city: String,
val lastDocumentationAt: Instant?,
)
) {
val displayAddress: String get() = "$street, $postalCode $city"
}
@@ -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 {
/**
* Ein Block gilt als bearbeitet, sobald mindestens eine seiner Optionen angehakt ist - das
* schließt die Freitext-Option (CheckItem.isFreeText = true) automatisch mit ein, da sie
* ebenfalls über [CheckItem.checked] gesetzt wird. Eine zusätzliche Prüfung auf vorhandenen
* Freitext-Inhalt ist seit der Umstellung des Freitexts auf eine Checkbox-Option nicht mehr
* nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt
* ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon
* Text eingegeben wurde.
*/
fun isEdited(block: DocumentationBlock): Boolean {
val hasFreeText = block.freeText.isNotBlank()
val hasCheckedItem = block.items.any { it.checked }
return hasFreeText || hasCheckedItem
return block.items.any { it.checked }
}
}
@@ -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),
block = currentBlock,
onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) },
onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0,
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
onPrevious = viewModel::onPreviousBlock,
@@ -128,7 +128,7 @@ private fun DocumentationBlockContent(
modifier: Modifier = Modifier,
block: DocumentationBlock,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (String) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean,
canGoNext: Boolean,
onPrevious: () -> Unit,
@@ -142,15 +142,17 @@ private fun DocumentationBlockContent(
) {
Text(block.title, style = MaterialTheme.typography.headlineSmall)
block.items.forEach { item -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) }
OutlinedTextField(
value = block.freeText,
onValueChange = onFreeTextChanged,
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
block.items.forEach { item ->
if (item.isFreeText) {
FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
} else {
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
@@ -179,3 +181,34 @@ private fun CheckItemRow(
Text(item.label)
}
}
/**
* Die Freitext-Option eines Blocks (Änderungswunsch: Freitext als weitere auswählbare Option
* statt eigenes Feld am Block). Das Textfeld ist deaktiviert, solange die zugehörige Checkbox
* nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option auch als
* "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
*/
@Composable
private fun FreeTextCheckItemRow(
item: CheckItem,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
)
Text(item.label)
}
OutlinedTextField(
value = item.freeTextValue,
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) },
enabled = item.checked,
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
modifier = Modifier.fillMaxWidth().padding(start = 40.dp),
minLines = 2,
)
}
}
@@ -51,7 +51,8 @@ class DocumentationEditViewModel(
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>()
init {
@@ -74,29 +75,37 @@ class DocumentationEditViewModel(
@OptIn(FlowPreview::class)
private fun observeFreeTextChanges() {
viewModelScope.launch {
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) ->
persistFreeText(blockStateId, text)
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (checkItemStateId, text) ->
persistFreeText(checkItemStateId, text)
}
}
}
private suspend fun persistFreeText(blockStateId: String, text: String) {
repository.updateFreeText(blockStateId, text)
private suspend fun persistFreeText(checkItemStateId: String, text: String) {
repository.updateFreeText(checkItemStateId, text)
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId)
if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId)
}
fun onFreeTextChanged(blockStateId: String, text: String) {
updateLocalFreeText(blockStateId, text)
pendingFreeText[blockStateId] = text
freeTextChanges.tryEmit(blockStateId to text)
/**
* [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText =
* true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine
* Checkbox-Option kein eigenes Feld am Block mehr.
*/
fun onFreeTextChanged(checkItemStateId: String, text: String) {
updateLocalFreeText(checkItemStateId, text)
pendingFreeText[checkItemStateId] = text
freeTextChanges.tryEmit(checkItemStateId to text)
}
private fun updateLocalFreeText(blockStateId: String, text: String) {
private fun updateLocalFreeText(checkItemStateId: String, text: String) {
_uiState.update { state ->
val documentation = state.documentation ?: return@update state
val updatedBlocks = documentation.blocks.map { block ->
if (block.blockStateId == blockStateId) block.copy(freeText = text) else block
val updatedItems = block.items.map { item ->
if (item.checkItemStateId == checkItemStateId) item.copy(freeTextValue = text) else item
}
block.copy(items = updatedItems)
}
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
}
@@ -142,7 +151,7 @@ class DocumentationEditViewModel(
val toFlush = pendingFreeText.toMap()
pendingFreeText.clear()
viewModelScope.launch {
toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) }
toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) }
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -15,6 +16,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -31,15 +33,18 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R
import de.medapp.wunddoku.ui.components.format.DATE_GROUP_SIZES
import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation
import de.medapp.wunddoku.ui.components.format.PHONE_GROUP_SIZES
/**
* Formular für Schritt 1 einer neuen Wunddokumentation (Aufgabenstellung Abschnitt 7) UND für
* das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über
* [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm].
* Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von
* Patienten-Stammdaten – unterschieden über [PatientFormViewModel.startsNewDocumentation].
*
* Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT)
* erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle
* Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst.
* Geburtsdatum und Telefonnummern nutzen dieselbe wiederverwendbare
* [DigitGroupVisualTransformation] (ui/components/format/FormattedInput.kt): Der zugrunde
* liegende Zustand im ViewModel bleibt jeweils eine reine Ziffernfolge, die Trennzeichen
* (Punkte bzw. Leerzeichen) werden ausschließlich für die Darstellung ergänzt.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -108,42 +113,62 @@ fun PatientFormScreen(
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.birthDateText,
onValueChange = viewModel::onBirthDateTextChanged,
value = uiState.birthDateDigits,
onValueChange = viewModel::onBirthDateDigitsChanged,
label = { Text(stringResource(R.string.patient_form_birth_date)) },
placeholder = { Text("JJJJ-MM-TT") },
placeholder = { Text("TT.MM.JJJJ") },
visualTransformation = DigitGroupVisualTransformation(DATE_GROUP_SIZES, '.'),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.street,
onValueChange = viewModel::onStreetChanged,
label = { Text(stringResource(R.string.patient_form_street)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.address,
onValueChange = viewModel::onAddressChanged,
label = { Text(stringResource(R.string.patient_form_address)) },
value = uiState.postalCode,
onValueChange = viewModel::onPostalCodeChanged,
label = { Text(stringResource(R.string.patient_form_postal_code)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.city,
onValueChange = viewModel::onCityChanged,
label = { Text(stringResource(R.string.patient_form_city)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.phone1,
onValueChange = viewModel::onPhone1Changed,
label = { Text(stringResource(R.string.patient_form_phone1)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.phone2,
onValueChange = viewModel::onPhone2Changed,
label = { Text(stringResource(R.string.patient_form_phone2)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.phone3,
onValueChange = viewModel::onPhone3Changed,
label = { Text(stringResource(R.string.patient_form_phone3)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
uiState.validationError?.let { error ->
Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
Text(error, color = MaterialTheme.colorScheme.error)
}
Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) {
@@ -10,6 +10,12 @@ import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS
import de.medapp.wunddoku.ui.components.format.PHONE_MAX_DIGITS
import de.medapp.wunddoku.ui.components.format.POSTAL_CODE_MAX_DIGITS
import de.medapp.wunddoku.ui.components.format.filterDigits
import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull
import de.medapp.wunddoku.ui.components.format.toGermanDateDigits
import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -17,16 +23,22 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
data class PatientFormUiState(
val isEditing: Boolean = false,
val isLoading: Boolean = true,
val firstName: String = "",
val lastName: String = "",
/** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */
val birthDateText: String = "",
val address: String = "",
/** Rohe Ziffernfolge (max. 8 Zeichen, TTMMJJJJ) - die Formatierung TT.MM.JJJJ erfolgt
* ausschließlich über eine VisualTransformation in der Compose-UI, s.
* ui/components/format/FormattedInput.kt. */
val birthDateDigits: String = "",
val street: String = "",
/** Rohe Ziffernfolge, max. 5 Zeichen (deutsche PLZ). */
val postalCode: String = "",
val city: String = "",
/** Rohe Ziffernfolgen (führende Nullen bleiben erhalten, da als String geführt) - die
* Leerzeichen-Formatierung erfolgt ausschließlich über eine VisualTransformation in der UI. */
val phone1: String = "",
val phone2: String = "",
val phone3: String = "",
@@ -40,8 +52,7 @@ data class PatientFormUiState(
)
/**
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab
* (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6).
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab.
*/
class PatientFormViewModel(
private val patientId: String?,
@@ -63,8 +74,10 @@ class PatientFormViewModel(
isLoading = false,
firstName = patient.firstName,
lastName = patient.lastName,
birthDateText = patient.birthDate.toString(),
address = patient.address,
birthDateDigits = patient.birthDate.toGermanDateDigits(),
street = patient.street,
postalCode = patient.postalCode,
city = patient.city,
phone1 = patient.phone1,
phone2 = patient.phone2.orEmpty(),
phone3 = patient.phone3.orEmpty(),
@@ -81,11 +94,25 @@ class PatientFormViewModel(
fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) }
fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) }
fun onBirthDateTextChanged(value: String) = _uiState.update { it.copy(birthDateText = value) }
fun onAddressChanged(value: String) = _uiState.update { it.copy(address = value) }
fun onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) }
fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) }
fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) }
fun onBirthDateDigitsChanged(raw: String) =
_uiState.update { it.copy(birthDateDigits = filterDigits(raw, DATE_MAX_DIGITS)) }
fun onStreetChanged(value: String) = _uiState.update { it.copy(street = value) }
fun onPostalCodeChanged(raw: String) =
_uiState.update { it.copy(postalCode = filterDigits(raw, POSTAL_CODE_MAX_DIGITS)) }
fun onCityChanged(value: String) = _uiState.update { it.copy(city = value) }
fun onPhone1Changed(raw: String) =
_uiState.update { it.copy(phone1 = filterDigits(raw, PHONE_MAX_DIGITS)) }
fun onPhone2Changed(raw: String) =
_uiState.update { it.copy(phone2 = filterDigits(raw, PHONE_MAX_DIGITS)) }
fun onPhone3Changed(raw: String) =
_uiState.update { it.copy(phone3 = filterDigits(raw, PHONE_MAX_DIGITS)) }
fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
@@ -105,18 +132,21 @@ class PatientFormViewModel(
fun save(skipDuplicateCheck: Boolean = false) {
val state = _uiState.value
val birthDate = try {
LocalDate.parse(state.birthDateText.trim())
} catch (e: Exception) {
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") }
val birthDate = germanDateDigitsToLocalDateOrNull(state.birthDateDigits)
if (birthDate == null) {
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum eingeben (TT.MM.JJJJ).") }
return
}
if (state.firstName.isBlank() || state.lastName.isBlank()) {
_uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") }
return
}
if (state.address.isBlank() || state.phone1.isBlank()) {
_uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") }
if (state.street.isBlank() || state.postalCode.isBlank() || state.city.isBlank()) {
_uiState.update { it.copy(validationError = "Straße, PLZ und Stadt dürfen nicht leer sein.") }
return
}
if (state.phone1.isBlank()) {
_uiState.update { it.copy(validationError = "Telefonnummer 1 darf nicht leer sein.") }
return
}
@@ -134,10 +164,12 @@ class PatientFormViewModel(
firstName = state.firstName.trim(),
lastName = state.lastName.trim(),
birthDate = birthDate,
address = state.address.trim(),
phone1 = state.phone1.trim(),
phone2 = state.phone2.trim().ifBlank { null },
phone3 = state.phone3.trim().ifBlank { null },
street = state.street.trim(),
postalCode = state.postalCode.trim(),
city = state.city.trim(),
phone1 = state.phone1,
phone2 = state.phone2.ifBlank { null },
phone3 = state.phone3.ifBlank { null },
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
updatedAt = Instant.EPOCH,
)
@@ -150,8 +182,8 @@ class PatientFormViewModel(
/**
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder
* nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder nur
* signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
*/
private suspend fun finish(resolvedPatientId: String) {
if (startsNewDocumentation) {
@@ -166,10 +198,6 @@ class PatientFormViewModel(
fun factory() = viewModelFactory {
initializer {
val container = wunddokuApplication().container
// createSavedStateHandle() ist die von Google empfohlene Art, innerhalb eines
// viewModelFactory-Initializers an das SavedStateHandle zu kommen; toRoute()
// dekodiert daraus direkt die typsichere Navigations-Route (Screen.PatientForm),
// statt Argumente manuell als lose Strings auszulesen.
val savedStateHandle = createSavedStateHandle()
val route = savedStateHandle.toRoute<Screen.PatientForm>()
PatientFormViewModel(
@@ -163,7 +163,7 @@ private fun PatientListItemCard(
"Geburtsdatum: ${item.birthDate.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall,
)
Text(item.address, style = MaterialTheme.typography.bodySmall)
Text(item.displayAddress, style = MaterialTheme.typography.bodySmall)
val lastDoc = item.lastDocumentationAt
if (lastDoc != null) {
Text(
+3 -1
View File
@@ -24,7 +24,9 @@
<string name="patient_form_first_name">Vorname</string>
<string name="patient_form_last_name">Nachname</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_phone2">Telefonnummer 2 (optional)</string>
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
-3
View File
@@ -5,9 +5,6 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)