Formularformatierung
This commit is contained in:
@@ -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,23 +297,34 @@ class DocumentationRepository(
|
||||
blockDefinitionId = blockDef.id,
|
||||
titleSnapshot = blockDef.title,
|
||||
blockOrder = blockDef.order,
|
||||
freeText = "",
|
||||
updatedAt = now,
|
||||
)
|
||||
)
|
||||
)
|
||||
checkItemStateDao.insertAll(
|
||||
blockDef.items.map { itemDef ->
|
||||
CheckItemStateEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
blockStateId = blockStateId,
|
||||
checkItemDefinitionId = itemDef.id,
|
||||
labelSnapshot = itemDef.label,
|
||||
itemOrder = itemDef.order,
|
||||
checked = false,
|
||||
)
|
||||
}
|
||||
|
||||
val normalItems = blockDef.items.map { itemDef ->
|
||||
CheckItemStateEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
blockStateId = blockStateId,
|
||||
checkItemDefinitionId = itemDef.id,
|
||||
labelSnapshot = itemDef.label,
|
||||
itemOrder = itemDef.order,
|
||||
checked = false,
|
||||
isFreeText = false,
|
||||
freeTextValue = "",
|
||||
)
|
||||
}
|
||||
val freeTextItem = CheckItemStateEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
blockStateId = blockStateId,
|
||||
checkItemDefinitionId = blockDef.freeTextDefinitionId,
|
||||
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
|
||||
itemOrder = blockDef.items.size + 1,
|
||||
checked = false,
|
||||
isFreeText = true,
|
||||
freeTextValue = "",
|
||||
)
|
||||
checkItemStateDao.insertAll(normalItems + freeTextItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
+44
-11
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-13
@@ -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(
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user