CORE-001 Android-Projekt anlegen Feature
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// Bewusst keine Gradle Version Catalog (libs.versions.toml) für dieses Ein-Modul-Projekt:
|
||||
// Ein Version Catalog lohnt sich vor allem bei mehreren Modulen mit gemeinsam genutzten
|
||||
// Versionen. Hier reicht eine einzige, gut lesbare build.gradle.kts.
|
||||
//
|
||||
// WICHTIG: Diese Versionsnummern wurden nach bestem Wissen anhand der zum Zeitpunkt der
|
||||
// Erstellung aktuellen offiziellen Android-/Kotlin-Dokumentation gewählt. Da dieses Projekt
|
||||
// in der Entwicklungsumgebung, in der es erzeugt wurde, nicht gegen die echten Google-/Maven-
|
||||
// Repositories synchronisiert werden konnte, prüfe beim ersten Öffnen in Android Studio bitte
|
||||
// insbesondere die KSP-Version (muss exakt zur Kotlin-Version passen, siehe
|
||||
// https://github.com/google/ksp/releases) und aktualisiere sie bei Bedarf über den
|
||||
// "Upgrade Assistant" bzw. die jeweiligen Release-Notes-Seiten.
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.medapp.wunddoku"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.medapp.wunddoku"
|
||||
// minSdk 26 (Android 8.0): java.time (Instant, LocalDate) ist ab API 26 nativ
|
||||
// verfügbar, ohne Desugaring-Konfiguration – vereinfacht die Datum/Zeit-Behandlung
|
||||
// im gesamten Projekt spürbar.
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
// Room exportiert bei exportSchema = true die Schema-Historie als JSON hierher – Grundlage
|
||||
// für nachvollziehbare, spätere Migrationen (Aufgabenstellung Abschnitt 16).
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// --- Kotlin / Coroutines ---
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") // für typsichere Nav-Routen
|
||||
|
||||
// --- AndroidX Core / Lifecycle ---
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
|
||||
// --- Jetpack Compose (über BOM, damit alle Compose-Bibliotheken zueinander passen) ---
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
// --- Navigation Compose (typsichere Routen) ---
|
||||
implementation("androidx.navigation:navigation-compose:2.8.4")
|
||||
|
||||
// --- Room ---
|
||||
val roomVersion = "2.8.4"
|
||||
implementation("androidx.room:room-runtime:$roomVersion")
|
||||
implementation("androidx.room:room-ktx:$roomVersion")
|
||||
ksp("androidx.room:room-compiler:$roomVersion")
|
||||
|
||||
// --- EXIF-Auswertung für Fotos (Abschnitt 10/11 der Architektur, ab Phase 4 genutzt) ---
|
||||
implementation("androidx.exifinterface:exifinterface:1.3.7")
|
||||
|
||||
// --- Adaptive Layouts (Smartphone/Tablet, Abschnitt 15 der Architektur) ---
|
||||
implementation("androidx.compose.material3.adaptive:adaptive:1.0.0")
|
||||
|
||||
// --- Tests ---
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("androidx.room:room-testing:$roomVersion")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Aktuell wird Minifizierung nicht verwendet (isMinifyEnabled = false in app/build.gradle.kts).
|
||||
# Diese Datei ist vorbereitet für den Fall, dass R8/ProGuard später für Release-Builds aktiviert
|
||||
# wird (Aufgabenstellung Abschnitt 6 der Vorgehensweise, Phase 6 "Codebereinigung").
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der
|
||||
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
|
||||
PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. -->
|
||||
|
||||
<application
|
||||
android:name=".WunddokuApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Wunddoku"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Wunddoku">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Einziger Weg, wie Dateien aus dem internen App-Speicher (filesDir) nach außen
|
||||
(PDF-Reader, Sharesheet) gelangen: gezielt über content://-URIs, nie über
|
||||
file://-URIs oder öffentlichen Speicher. Siehe Abschnitt 13/10 der Architektur. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.medapp.wunddoku
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.medapp.wunddoku.ui.navigation.WunddokuNavGraph
|
||||
import de.medapp.wunddoku.ui.theme.WunddokuTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// FLAG_SECURE (Freigabe-Entscheidung aus Phase 1, Architektur-Dokument Abschnitt 13):
|
||||
// verhindert, dass Wunddaten in Screenshots oder in der App-Switcher-Vorschau
|
||||
// sichtbar werden, da die App sensible Patientendaten verarbeitet.
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
|
||||
setContent {
|
||||
WunddokuApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WunddokuApp() {
|
||||
WunddokuTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
WunddokuNavGraph()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.medapp.wunddoku
|
||||
|
||||
import android.app.Application
|
||||
import de.medapp.wunddoku.di.AppContainer
|
||||
|
||||
class WunddokuApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
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.
|
||||
*/
|
||||
@Dao
|
||||
interface BlockStateDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insertAll(blockStates: List<BlockStateEntity>)
|
||||
|
||||
@Query("SELECT * FROM block_states WHERE id = :id")
|
||||
suspend fun getById(id: String): BlockStateEntity?
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
||||
|
||||
@Dao
|
||||
interface CheckItemStateDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insertAll(checkItemStates: List<CheckItemStateEntity>)
|
||||
|
||||
@Query("SELECT * FROM check_item_states WHERE blockStateId = :blockStateId ORDER BY itemOrder")
|
||||
suspend fun getForBlockState(blockStateId: String): List<CheckItemStateEntity>
|
||||
|
||||
@Query("UPDATE check_item_states SET checked = :checked WHERE id = :id")
|
||||
suspend fun setChecked(id: String, checked: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
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
|
||||
interface DocumentationDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(documentation: DocumentationEntity)
|
||||
|
||||
@Update
|
||||
suspend fun update(documentation: DocumentationEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(documentation: DocumentationEntity)
|
||||
|
||||
@Query("SELECT * FROM documentations WHERE id = :id")
|
||||
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.
|
||||
*/
|
||||
@Transaction
|
||||
@Query("SELECT * FROM documentations ORDER BY createdAt DESC")
|
||||
fun observeAllWithPatient(): Flow<List<DocumentationWithPatient>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT d.* FROM documentations d
|
||||
JOIN patients p ON p.id = d.patientId
|
||||
WHERE p.lastName LIKE '%' || :query || '%'
|
||||
OR p.firstName LIKE '%' || :query || '%'
|
||||
OR p.birthDate LIKE '%' || :query || '%'
|
||||
ORDER BY d.createdAt DESC
|
||||
"""
|
||||
)
|
||||
fun search(query: String): Flow<List<DocumentationWithPatient>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM documentations WHERE id = :id")
|
||||
suspend fun getFullDocumentation(id: String): DocumentationWithDetails?
|
||||
|
||||
/**
|
||||
* Für "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9): lädt die zuletzt erstellte
|
||||
* Dokumentation eines Patienten inklusive aller Blöcke/Checkboxen (Fotos/Unterschriften
|
||||
* werden je nach Kopiersemantik separat behandelt, s. DocumentationRepository).
|
||||
*/
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM documentations
|
||||
WHERE patientId = :patientId
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getLatestForPatient(patientId: String): DocumentationWithDetails?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM documentations WHERE patientId = :patientId")
|
||||
suspend fun countForPatient(patientId: String): Int
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import de.medapp.wunddoku.data.entity.PatientEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* [PatientEntity] zusammen mit dem Erstellungsdatum der zuletzt angelegten Dokumentation dieses
|
||||
* Patienten (oder `null`, falls noch keine existiert). Wird über eine Korrelations-Subquery in
|
||||
* einem einzigen Datenbankzugriff berechnet, statt pro Patient eine eigene Folgeabfrage
|
||||
* auszuführen (N+1-Problem) – wichtig für die geforderte Performance bei größeren Datenmengen
|
||||
* (Aufgabenstellung Abschnitt 6).
|
||||
*/
|
||||
data class PatientWithLastDocumentation(
|
||||
@Embedded val patient: PatientEntity,
|
||||
val lastDocumentationAt: Long?,
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface PatientDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun insert(patient: PatientEntity)
|
||||
|
||||
@Update
|
||||
suspend fun update(patient: PatientEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(patient: PatientEntity)
|
||||
|
||||
@Query("SELECT * FROM patients WHERE id = :id")
|
||||
suspend fun getById(id: String): PatientEntity?
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT p.*, (SELECT MAX(d.createdAt) FROM documentations d WHERE d.patientId = p.id) AS lastDocumentationAt
|
||||
FROM patients p
|
||||
ORDER BY p.lastName, p.firstName
|
||||
"""
|
||||
)
|
||||
fun observeAllWithLastDocumentation(): Flow<List<PatientWithLastDocumentation>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT p.*, (SELECT MAX(d.createdAt) FROM documentations d WHERE d.patientId = p.id) AS lastDocumentationAt
|
||||
FROM patients p
|
||||
WHERE p.lastName LIKE '%' || :query || '%'
|
||||
OR p.firstName LIKE '%' || :query || '%'
|
||||
OR p.birthDate LIKE '%' || :query || '%'
|
||||
ORDER BY p.lastName, p.firstName
|
||||
"""
|
||||
)
|
||||
fun searchWithLastDocumentation(query: String): Flow<List<PatientWithLastDocumentation>>
|
||||
|
||||
/**
|
||||
* Wird beim Anlegen einer neuen Dokumentation verwendet, um zu prüfen, ob ein Patient mit
|
||||
* denselben Stammdaten bereits existiert (Aufgabenstellung Abschnitt 7 Schritt 1: "möglichst
|
||||
* ein vorhandener Patient ausgewählt werden können, statt einen Duplikatdatensatz
|
||||
* anzulegen"). Exakter Abgleich auf Name + Geburtsdatum als einfache, nachvollziehbare
|
||||
* Heuristik für V1.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM patients
|
||||
WHERE firstName = :firstName AND lastName = :lastName AND birthDate = :birthDate
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun findExisting(firstName: String, lastName: String, birthDate: String): PatientEntity?
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM documentations WHERE patientId = :patientId)")
|
||||
suspend fun hasDocumentations(patientId: String): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import de.medapp.wunddoku.data.entity.PhotoEntity
|
||||
|
||||
@Dao
|
||||
interface PhotoDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(photo: PhotoEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(photo: PhotoEntity)
|
||||
|
||||
@Query("SELECT * FROM photos WHERE id = :id")
|
||||
suspend fun getById(id: String): PhotoEntity?
|
||||
|
||||
@Query("SELECT * FROM photos WHERE documentationId = :documentationId ORDER BY orderIndex")
|
||||
suspend fun getForDocumentation(documentationId: String): List<PhotoEntity>
|
||||
|
||||
@Query("SELECT COALESCE(MAX(orderIndex), -1) FROM photos WHERE documentationId = :documentationId")
|
||||
suspend fun getMaxOrderIndex(documentationId: String): Int
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.medapp.wunddoku.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import de.medapp.wunddoku.data.entity.SignatureEntity
|
||||
|
||||
@Dao
|
||||
interface SignatureDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(signature: SignatureEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(signature: SignatureEntity)
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE id = :id")
|
||||
suspend fun getById(id: String): SignatureEntity?
|
||||
|
||||
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId")
|
||||
suspend fun getForDocumentation(documentationId: String): List<SignatureEntity>
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.medapp.wunddoku.data.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
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.entity.BlockStateEntity
|
||||
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
|
||||
import de.medapp.wunddoku.data.entity.DocumentationEntity
|
||||
import de.medapp.wunddoku.data.entity.PatientEntity
|
||||
import de.medapp.wunddoku.data.entity.PhotoEntity
|
||||
import de.medapp.wunddoku.data.entity.SignatureEntity
|
||||
import de.medapp.wunddoku.data.entity.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.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
PatientEntity::class,
|
||||
DocumentationEntity::class,
|
||||
BlockStateEntity::class,
|
||||
CheckItemStateEntity::class,
|
||||
PhotoEntity::class,
|
||||
SignatureEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(DocumentationStatusConverter::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun patientDao(): PatientDao
|
||||
abstract fun documentationDao(): DocumentationDao
|
||||
abstract fun blockStateDao(): BlockStateDao
|
||||
abstract fun checkItemStateDao(): CheckItemStateDao
|
||||
abstract fun photoDao(): PhotoDao
|
||||
abstract fun signatureDao(): SignatureDao
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "wunddoku.db"
|
||||
|
||||
@Volatile
|
||||
private var instance: AppDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
DATABASE_NAME,
|
||||
).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.medapp.wunddoku.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room-Entity für den Zustand eines einzelnen Dokumentationsblocks innerhalb einer konkreten
|
||||
* Wunddokumentation. Generische Struktur statt 20 fester Spalten/Tabellen (Architektur-Dokument
|
||||
* Abschnitt 2.3a) – neue Blöcke im [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]
|
||||
* erfordern keine Schema-Migration.
|
||||
*
|
||||
* [blockDefinitionId] verweist auf die ID im statischen Katalog, ist aber bewusst KEIN
|
||||
* 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.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "block_states",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DocumentationEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["documentationId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["documentationId"]),
|
||||
Index(value = ["documentationId", "blockDefinitionId"], unique = true),
|
||||
],
|
||||
)
|
||||
data class BlockStateEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val documentationId: String,
|
||||
val blockDefinitionId: String,
|
||||
val titleSnapshot: String,
|
||||
val blockOrder: Int,
|
||||
val freeText: String,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.medapp.wunddoku.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room-Entity für den Zustand einer einzelnen Checkbox innerhalb eines [BlockStateEntity].
|
||||
*
|
||||
* [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche
|
||||
* Begründung, auf Checkbox-Ebene.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "check_item_states",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = BlockStateEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["blockStateId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["blockStateId"]),
|
||||
Index(value = ["blockStateId", "checkItemDefinitionId"], unique = true),
|
||||
],
|
||||
)
|
||||
data class CheckItemStateEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val blockStateId: String,
|
||||
val checkItemDefinitionId: String,
|
||||
val labelSnapshot: String,
|
||||
val itemOrder: Int,
|
||||
val checked: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.medapp.wunddoku.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
|
||||
/**
|
||||
* Room-Entity für eine Wunddokumentation.
|
||||
*
|
||||
* Foreign-Key-Verhalten (Architektur-Dokument Abschnitt 2.3f):
|
||||
* - patientId -> Patient.id: RESTRICT. Ein Patient mit vorhandenen Dokumentationen kann in
|
||||
* der Datenbank NICHT gelöscht werden. Das setzt die Anforderung "kein versehentliches
|
||||
* Cascade-Delete von Dokumentationen" technisch durch – die UI muss das vorher explizit
|
||||
* abfangen (siehe PatientRepository.hasDocumentations).
|
||||
* - continuedFromDocumentationId -> Documentation.id: SET_NULL. Löschen einer alten
|
||||
* Dokumentation darf eine neuere, davon "fortgesetzte" Dokumentation weder blockieren noch
|
||||
* mitreißen; der Verweis wird beim Löschen des Vorgängers einfach entfernt.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "documentations",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = PatientEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["patientId"],
|
||||
onDelete = ForeignKey.RESTRICT,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = DocumentationEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["continuedFromDocumentationId"],
|
||||
onDelete = ForeignKey.SET_NULL,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["patientId", "createdAt"]),
|
||||
Index(value = ["status"]),
|
||||
Index(value = ["continuedFromDocumentationId"]),
|
||||
],
|
||||
)
|
||||
data class DocumentationEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val patientId: String,
|
||||
val status: DocumentationStatus,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val continuedFromDocumentationId: String?,
|
||||
val pdfLastGeneratedAt: Long?,
|
||||
val catalogVersion: Int,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.medapp.wunddoku.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room-Entity für einen Patienten. [id] ist eine UUID (kein Auto-Increment), damit Datensätze
|
||||
* geräteübergreifend eindeutig bleiben – wichtig für die spätere optionale WLAN-Synchronisation
|
||||
* (Architektur-Dokument Abschnitt 2.1).
|
||||
*
|
||||
* [birthDate] wird als ISO-8601-String (z. B. "1980-03-14") gespeichert statt über einen
|
||||
* 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.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "patients",
|
||||
indices = [
|
||||
Index(value = ["lastName", "firstName"]),
|
||||
Index(value = ["birthDate"]),
|
||||
],
|
||||
)
|
||||
data class PatientEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val birthDate: String,
|
||||
val address: String,
|
||||
val phone1: String,
|
||||
val phone2: String?,
|
||||
val phone3: String?,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.medapp.wunddoku.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room-Entity für ein an eine Dokumentation angehängtes Foto.
|
||||
*
|
||||
* [fileName] ist ein relativer Pfad innerhalb von context.filesDir/photos/ – kein BLOB, keine
|
||||
* absolute Pfadangabe (robust gegenüber möglichen Änderungen des App-Datenverzeichnisses durch
|
||||
* das System). Siehe Aufgabenstellung Abschnitt 10.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "photos",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DocumentationEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["documentationId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["documentationId", "orderIndex"]),
|
||||
],
|
||||
)
|
||||
data class PhotoEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val documentationId: String,
|
||||
val fileName: String,
|
||||
val orderIndex: Int,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.medapp.wunddoku.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an
|
||||
* DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen
|
||||
* (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter
|
||||
* context.filesDir/signatures/ – kein BLOB in SQLite.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "signatures",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DocumentationEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["documentationId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["documentationId"]),
|
||||
],
|
||||
)
|
||||
data class SignatureEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val documentationId: String,
|
||||
val signerName: String?,
|
||||
val fileName: String,
|
||||
val createdAt: Long,
|
||||
)
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package de.medapp.wunddoku.data.entity.converter
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
|
||||
/**
|
||||
* Speichert [DocumentationStatus] als lesbaren Text (z. B. "COMPLETED"), NICHT als
|
||||
* Ordinal-Index (0, 1, ...).
|
||||
*
|
||||
* Kotlin-Hinweis: `@TypeConverter` markiert Funktionen, die Room bei Bedarf automatisch
|
||||
* aufruft, um einen Typ, den SQLite nicht nativ kennt (hier: das Enum), in einen
|
||||
* speicherbaren Typ (hier: String) umzuwandeln und zurück. Ich verwende bewusst einen
|
||||
* expliziten Konverter statt mich auf automatisches Enum-Handling neuerer Room-Versionen zu
|
||||
* verlassen – das macht das Verhalten unabhängig von der genauen Room-Version nachvollziehbar.
|
||||
*
|
||||
* Der Ordinal-Wert eines Enums hängt von der Deklarationsreihenfolge im Code ab. Würde später
|
||||
* z. B. CANCELLED vor COMPLETED eingefügt, würden sich bei Ordinal-Speicherung alle
|
||||
* bestehenden Datensätze plötzlich falsch zuordnen. Der Name ist stabil gegenüber
|
||||
* Reihenfolgeänderungen und zusätzlich beim Debuggen in einem SQLite-Browser direkt lesbar.
|
||||
*/
|
||||
class DocumentationStatusConverter {
|
||||
|
||||
@TypeConverter
|
||||
fun fromStatus(status: DocumentationStatus): String = status.name
|
||||
|
||||
@TypeConverter
|
||||
fun toStatus(value: String): DocumentationStatus = DocumentationStatus.valueOf(value)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.medapp.wunddoku.data.entity.relations
|
||||
|
||||
import androidx.room.Embedded
|
||||
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
|
||||
|
||||
/**
|
||||
* Verschachtelte Room-Relation: ein Block zusammen mit seinen Checkbox-Zuständen.
|
||||
*
|
||||
* Kotlin/Room-Hinweis: `@Embedded` bedeutet, dass die Spalten von [BlockStateEntity] direkt in
|
||||
* dieses Objekt "eingebettet" werden (kein separates verschachteltes Objekt in der
|
||||
* SQL-Ergebnisstruktur). `@Relation` lässt Room automatisch eine zweite Query ausführen, um
|
||||
* alle [CheckItemStateEntity]-Zeilen zu laden, deren `blockStateId`-Spalte zur `id`-Spalte
|
||||
* dieses BlockState passt.
|
||||
*/
|
||||
data class BlockStateWithItems(
|
||||
@Embedded val blockState: BlockStateEntity,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "blockStateId",
|
||||
)
|
||||
val checkItems: List<CheckItemStateEntity>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vollständige Dokumentation inkl. aller Blöcke (mit ihren Checkboxen), Fotos und
|
||||
* Unterschriften. Wird ausschließlich über `@Transaction`-Queries in [de.medapp.wunddoku.data.dao.DocumentationDao]
|
||||
* geladen, damit das Ergebnis konsistent ist (Aufgabenstellung Abschnitt 16, letzter Punkt).
|
||||
*
|
||||
* Kotlin/Room-Hinweis: Eine mehrstufig verschachtelte `@Relation` (hier: Blöcke, die selbst
|
||||
* wieder ihre Checkboxen per `@Relation` laden) wird von Room unterstützt, solange bei
|
||||
* `entity = ...` explizit die zugrunde liegende Room-Entity angegeben wird – auch wenn der
|
||||
* Ziel-Typ ([BlockStateWithItems]) selbst kein Entity, sondern eine weitere POJO/Relation ist.
|
||||
*/
|
||||
data class DocumentationWithDetails(
|
||||
@Embedded val documentation: DocumentationEntity,
|
||||
@Relation(
|
||||
entity = BlockStateEntity::class,
|
||||
parentColumn = "id",
|
||||
entityColumn = "documentationId",
|
||||
)
|
||||
val blocks: List<BlockStateWithItems>,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "documentationId",
|
||||
)
|
||||
val photos: List<PhotoEntity>,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "documentationId",
|
||||
)
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
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.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 java.time.Instant
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
|
||||
id = documentation.id,
|
||||
patientId = documentation.patientId,
|
||||
status = documentation.status,
|
||||
createdAt = Instant.ofEpochMilli(documentation.createdAt),
|
||||
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
|
||||
continuedFromDocumentationId = documentation.continuedFromDocumentationId,
|
||||
catalogVersion = documentation.catalogVersion,
|
||||
blocks = blocks
|
||||
.sortedBy { it.blockState.blockOrder }
|
||||
.map { it.toDomain() },
|
||||
photos = photos
|
||||
.sortedBy { it.orderIndex }
|
||||
.map {
|
||||
de.medapp.wunddoku.domain.model.Photo(
|
||||
id = it.id,
|
||||
documentationId = it.documentationId,
|
||||
fileName = it.fileName,
|
||||
orderIndex = it.orderIndex,
|
||||
createdAt = Instant.ofEpochMilli(it.createdAt),
|
||||
)
|
||||
},
|
||||
signatures = signatures.map {
|
||||
de.medapp.wunddoku.domain.model.Signature(
|
||||
id = it.id,
|
||||
documentationId = it.documentationId,
|
||||
signerName = it.signerName,
|
||||
fileName = it.fileName,
|
||||
createdAt = Instant.ofEpochMilli(it.createdAt),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
|
||||
blockStateId = blockState.id,
|
||||
definitionId = blockState.blockDefinitionId,
|
||||
title = blockState.titleSnapshot,
|
||||
order = blockState.blockOrder,
|
||||
freeText = blockState.freeText,
|
||||
items = checkItems
|
||||
.sortedBy { it.itemOrder }
|
||||
.map { it.toDomain() },
|
||||
)
|
||||
|
||||
fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
|
||||
checkItemStateId = id,
|
||||
definitionId = checkItemDefinitionId,
|
||||
label = labelSnapshot,
|
||||
order = itemOrder,
|
||||
checked = checked,
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.medapp.wunddoku.data.mapper
|
||||
|
||||
import de.medapp.wunddoku.data.entity.PatientEntity
|
||||
import de.medapp.wunddoku.domain.model.Patient
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Wandelt zwischen der Room-Entity [PatientEntity] und dem Domain-Modell [Patient] um.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
fun PatientEntity.toDomain(): Patient = Patient(
|
||||
id = id,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
birthDate = LocalDate.parse(birthDate),
|
||||
address = address,
|
||||
phone1 = phone1,
|
||||
phone2 = phone2,
|
||||
phone3 = phone3,
|
||||
createdAt = Instant.ofEpochMilli(createdAt),
|
||||
updatedAt = Instant.ofEpochMilli(updatedAt),
|
||||
)
|
||||
|
||||
fun Patient.toEntity(): PatientEntity = PatientEntity(
|
||||
id = id,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd)
|
||||
address = address,
|
||||
phone1 = phone1,
|
||||
phone2 = phone2,
|
||||
phone3 = phone3,
|
||||
createdAt = createdAt.toEpochMilli(),
|
||||
updatedAt = updatedAt.toEpochMilli(),
|
||||
)
|
||||
@@ -0,0 +1,291 @@
|
||||
package de.medapp.wunddoku.data.repository
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
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.PhotoDao
|
||||
import de.medapp.wunddoku.data.dao.SignatureDao
|
||||
import de.medapp.wunddoku.data.database.AppDatabase
|
||||
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.PhotoEntity
|
||||
import de.medapp.wunddoku.data.entity.SignatureEntity
|
||||
import de.medapp.wunddoku.data.mapper.toDomain
|
||||
import de.medapp.wunddoku.data.mapper.toSummary
|
||||
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
|
||||
import de.medapp.wunddoku.domain.model.Documentation
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
||||
import de.medapp.wunddoku.domain.model.Photo
|
||||
import de.medapp.wunddoku.domain.model.Signature
|
||||
import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.signature.SignatureFileStorage
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.time.Instant
|
||||
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).
|
||||
*/
|
||||
class DocumentationRepository(
|
||||
private val documentationDao: DocumentationDao,
|
||||
private val blockStateDao: BlockStateDao,
|
||||
private val checkItemStateDao: CheckItemStateDao,
|
||||
private val photoDao: PhotoDao,
|
||||
private val signatureDao: SignatureDao,
|
||||
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() } }
|
||||
|
||||
fun search(query: String): Flow<List<DocumentationSummary>> =
|
||||
documentationDao.search(query).map { list -> list.map { it.toSummary() } }
|
||||
|
||||
suspend fun getFullDocumentation(id: String): Documentation? =
|
||||
documentationDao.getFullDocumentation(id)?.toDomain()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
suspend fun createEmpty(patientId: String): Documentation = db.withTransaction {
|
||||
val now = Instant.now().toEpochMilli()
|
||||
val documentationId = UUID.randomUUID().toString()
|
||||
|
||||
documentationDao.insert(
|
||||
DocumentationEntity(
|
||||
id = documentationId,
|
||||
patientId = patientId,
|
||||
status = DocumentationStatus.IN_PROGRESS,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
continuedFromDocumentationId = null,
|
||||
pdfLastGeneratedAt = null,
|
||||
catalogVersion = DocumentationCatalog.CATALOG_VERSION,
|
||||
)
|
||||
)
|
||||
|
||||
insertBlocksFromCatalog(documentationId, now)
|
||||
|
||||
documentationDao.getFullDocumentation(documentationId)!!.toDomain()
|
||||
}
|
||||
|
||||
/**
|
||||
* "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9, präzisiert in Architektur-
|
||||
* Dokument Abschnitt 6):
|
||||
* - 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.
|
||||
* - 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)
|
||||
*/
|
||||
suspend fun continueDocumentation(
|
||||
sourceDocumentationId: String,
|
||||
copyPhotos: Boolean = false,
|
||||
): Documentation = db.withTransaction {
|
||||
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
|
||||
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
|
||||
|
||||
val now = Instant.now().toEpochMilli()
|
||||
val newDocumentationId = UUID.randomUUID().toString()
|
||||
|
||||
documentationDao.insert(
|
||||
DocumentationEntity(
|
||||
id = newDocumentationId,
|
||||
patientId = source.documentation.patientId,
|
||||
status = DocumentationStatus.IN_PROGRESS,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
continuedFromDocumentationId = sourceDocumentationId,
|
||||
pdfLastGeneratedAt = null,
|
||||
catalogVersion = source.documentation.catalogVersion,
|
||||
)
|
||||
)
|
||||
|
||||
// Blöcke + Checkboxen 1:1 von der Quelle übernehmen (inkl. Snapshots/Reihenfolge)
|
||||
for (sourceBlock in source.blocks) {
|
||||
val newBlockStateId = UUID.randomUUID().toString()
|
||||
blockStateDao.insertAll(
|
||||
listOf(
|
||||
BlockStateEntity(
|
||||
id = newBlockStateId,
|
||||
documentationId = newDocumentationId,
|
||||
blockDefinitionId = sourceBlock.blockState.blockDefinitionId,
|
||||
titleSnapshot = sourceBlock.blockState.titleSnapshot,
|
||||
blockOrder = sourceBlock.blockState.blockOrder,
|
||||
freeText = sourceBlock.blockState.freeText,
|
||||
updatedAt = now,
|
||||
)
|
||||
)
|
||||
)
|
||||
checkItemStateDao.insertAll(
|
||||
sourceBlock.checkItems.map { item ->
|
||||
CheckItemStateEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
blockStateId = newBlockStateId,
|
||||
checkItemDefinitionId = item.checkItemDefinitionId,
|
||||
labelSnapshot = item.labelSnapshot,
|
||||
itemOrder = item.itemOrder,
|
||||
checked = item.checked,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
|
||||
|
||||
if (copyPhotos) {
|
||||
for (sourcePhoto in source.photos) {
|
||||
val copiedFileName = photoStorage.duplicate(sourcePhoto.fileName)
|
||||
photoDao.insert(
|
||||
PhotoEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
documentationId = newDocumentationId,
|
||||
fileName = copiedFileName,
|
||||
orderIndex = sourcePhoto.orderIndex,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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())
|
||||
}
|
||||
|
||||
suspend fun setChecked(checkItemStateId: String, checked: Boolean) {
|
||||
checkItemStateDao.setChecked(checkItemStateId, checked)
|
||||
}
|
||||
|
||||
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
|
||||
val fileName = photoStorage.copyFrom(sourceUri)
|
||||
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||
val now = Instant.now()
|
||||
val entity = PhotoEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
documentationId = documentationId,
|
||||
fileName = fileName,
|
||||
orderIndex = nextOrder,
|
||||
createdAt = now.toEpochMilli(),
|
||||
)
|
||||
photoDao.insert(entity)
|
||||
return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 {
|
||||
val fileName = signatureFileStorage.save(bitmap)
|
||||
val now = Instant.now()
|
||||
val entity = SignatureEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
documentationId = documentationId,
|
||||
signerName = signerName,
|
||||
fileName = fileName,
|
||||
createdAt = now.toEpochMilli(),
|
||||
)
|
||||
signatureDao.insert(entity)
|
||||
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now)
|
||||
}
|
||||
|
||||
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */
|
||||
suspend fun removeSignature(signatureId: String) {
|
||||
val signature = signatureDao.getById(signatureId) ?: return
|
||||
signatureDao.delete(signature)
|
||||
runCatching { signatureFileStorage.delete(signature.fileName) }
|
||||
}
|
||||
|
||||
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)
|
||||
photos.forEach { runCatching { photoStorage.delete(it.fileName) } }
|
||||
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } }
|
||||
}
|
||||
|
||||
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
|
||||
for (blockDef in DocumentationCatalog.blocks) {
|
||||
val blockStateId = UUID.randomUUID().toString()
|
||||
blockStateDao.insertAll(
|
||||
listOf(
|
||||
BlockStateEntity(
|
||||
id = blockStateId,
|
||||
documentationId = documentationId,
|
||||
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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.medapp.wunddoku.data.repository
|
||||
|
||||
import de.medapp.wunddoku.data.dao.PatientDao
|
||||
import de.medapp.wunddoku.data.dao.PatientWithLastDocumentation
|
||||
import de.medapp.wunddoku.data.mapper.toDomain
|
||||
import de.medapp.wunddoku.data.mapper.toEntity
|
||||
import de.medapp.wunddoku.domain.model.Patient
|
||||
import de.medapp.wunddoku.domain.model.PatientListItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
|
||||
class PatientRepository(
|
||||
private val patientDao: PatientDao,
|
||||
) {
|
||||
fun observeAll(): Flow<List<PatientListItem>> =
|
||||
patientDao.observeAllWithLastDocumentation().map { list -> list.map { it.toListItem() } }
|
||||
|
||||
fun search(query: String): Flow<List<PatientListItem>> =
|
||||
patientDao.searchWithLastDocumentation(query).map { list -> list.map { it.toListItem() } }
|
||||
|
||||
suspend fun getById(id: String): Patient? = patientDao.getById(id)?.toDomain()
|
||||
|
||||
suspend fun findExisting(firstName: String, lastName: String, birthDate: LocalDate): Patient? =
|
||||
patientDao.findExisting(firstName, lastName, birthDate.toString())?.toDomain()
|
||||
|
||||
fun newId(): String = UUID.randomUUID().toString()
|
||||
|
||||
/**
|
||||
* Legt einen neuen Patienten an oder aktualisiert einen bestehenden, je nachdem, ob unter
|
||||
* [patient.id] bereits ein Datensatz existiert. `createdAt`/`updatedAt` werden zentral hier
|
||||
* gepflegt, damit ViewModels das nicht selbst verwalten müssen.
|
||||
*/
|
||||
suspend fun upsert(patient: Patient): Patient {
|
||||
val existing = patientDao.getById(patient.id)
|
||||
val now = Instant.now()
|
||||
val toPersist = if (existing == null) {
|
||||
patient.copy(createdAt = now, updatedAt = now)
|
||||
} else {
|
||||
patient.copy(createdAt = Instant.ofEpochMilli(existing.createdAt), updatedAt = now)
|
||||
}
|
||||
if (existing == null) {
|
||||
patientDao.insert(toPersist.toEntity())
|
||||
} else {
|
||||
patientDao.update(toPersist.toEntity())
|
||||
}
|
||||
return toPersist
|
||||
}
|
||||
|
||||
suspend fun hasDocumentations(patientId: String): Boolean =
|
||||
patientDao.hasDocumentations(patientId)
|
||||
|
||||
/**
|
||||
* Löscht den Patienten. Der Aufrufer (ViewModel) sollte vorher [hasDocumentations] prüfen,
|
||||
* um dem Nutzer eine verständliche Fehlermeldung statt einer rohen Datenbank-Exception zu
|
||||
* zeigen (Aufgabenstellung Abschnitt 20). Die eigentliche Integrität wird zusätzlich durch
|
||||
* `ON DELETE RESTRICT` in der Datenbank selbst garantiert (Architektur-Dokument Abschnitt 2.3f).
|
||||
*/
|
||||
suspend fun delete(patientId: String) {
|
||||
val entity = patientDao.getById(patientId) ?: return
|
||||
patientDao.delete(entity)
|
||||
}
|
||||
|
||||
private fun PatientWithLastDocumentation.toListItem(): PatientListItem = PatientListItem(
|
||||
id = patient.id,
|
||||
fullName = "${patient.firstName} ${patient.lastName}",
|
||||
birthDate = LocalDate.parse(patient.birthDate),
|
||||
address = patient.address,
|
||||
lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.medapp.wunddoku.di
|
||||
|
||||
import android.content.Context
|
||||
import de.medapp.wunddoku.data.database.AppDatabase
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||
import de.medapp.wunddoku.media.InternalPhotoStorage
|
||||
import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.signature.InternalSignatureFileStorage
|
||||
import de.medapp.wunddoku.signature.SignatureFileStorage
|
||||
|
||||
/**
|
||||
* Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories,
|
||||
* eine Handvoll ViewModels, wenige Storage-/Exporter-Klassen) reicht eine von Hand geschriebene
|
||||
* Objekt-Fabrik. Das spart eine zusätzliche Abhängigkeit samt Annotationsverarbeitung, die für
|
||||
* einen Kotlin-Einsteiger zusätzliche "Magie" ohne unmittelbaren Lernwert wäre (Architektur-
|
||||
* Dokument Abschnitt 13). Bei deutlichem Projektwachstum ist das später nachrüstbar.
|
||||
*
|
||||
* Alle Objekte hier sind bewusst `by lazy`: Sie werden erst beim ersten Zugriff erzeugt, nicht
|
||||
* bereits beim App-Start – unnötig für eine Klasse, die ohnehin nur einmal pro Prozesslebenszeit
|
||||
* instanziiert wird, aber ein günstiges, verbreitetes Kotlin-Idiom für "einmalig und träge
|
||||
* initialisieren".
|
||||
*/
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
private val database: AppDatabase by lazy { AppDatabase.getInstance(context) }
|
||||
|
||||
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
|
||||
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) }
|
||||
|
||||
val patientRepository: PatientRepository by lazy {
|
||||
PatientRepository(patientDao = database.patientDao())
|
||||
}
|
||||
|
||||
val documentationRepository: DocumentationRepository by lazy {
|
||||
DocumentationRepository(
|
||||
documentationDao = database.documentationDao(),
|
||||
blockStateDao = database.blockStateDao(),
|
||||
checkItemStateDao = database.checkItemStateDao(),
|
||||
photoDao = database.photoDao(),
|
||||
signatureDao = database.signatureDao(),
|
||||
photoStorage = photoStorage,
|
||||
signatureFileStorage = signatureFileStorage,
|
||||
db = database,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.medapp.wunddoku.di
|
||||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import de.medapp.wunddoku.WunddokuApplication
|
||||
|
||||
/**
|
||||
* Kleiner Helfer, um innerhalb eines `viewModelFactory { initializer { ... } }`-Blocks an das
|
||||
* [AppContainer] der [WunddokuApplication] zu kommen, ohne Hilt/Koin.
|
||||
*
|
||||
* Kotlin-Hinweis: `CreationExtras` ist der von Compose/Activity bereitgestellte Kontext, aus
|
||||
* dem sich über den Standard-Schlüssel `APPLICATION_KEY` die aktuelle `Application`-Instanz
|
||||
* auslesen lässt – der von Google empfohlene, moderne Weg für manuelles ViewModel-DI ohne
|
||||
* Hilt (`viewModelFactory { initializer { MeinViewModel(extras.wunddokuApplication().container...) } }`).
|
||||
*/
|
||||
fun CreationExtras.wunddokuApplication(): WunddokuApplication =
|
||||
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as WunddokuApplication
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.medapp.wunddoku.domain.catalog
|
||||
|
||||
/**
|
||||
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
|
||||
*
|
||||
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter
|
||||
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte").
|
||||
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser
|
||||
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden.
|
||||
*
|
||||
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
|
||||
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
|
||||
* Abschnitt 2.3b.
|
||||
*
|
||||
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
|
||||
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
|
||||
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
|
||||
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
|
||||
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots).
|
||||
*/
|
||||
object DocumentationCatalog {
|
||||
|
||||
const val CATALOG_VERSION: Int = 1
|
||||
|
||||
val blocks: List<BlockDefinition> = listOf(
|
||||
BlockDefinition(
|
||||
id = "block_01",
|
||||
title = "Block 1",
|
||||
order = 1,
|
||||
items = listOf(
|
||||
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1),
|
||||
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
id = "block_02",
|
||||
title = "Block 2",
|
||||
order = 2,
|
||||
items = listOf(
|
||||
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1),
|
||||
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
|
||||
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
id = "block_03",
|
||||
title = "Block 3",
|
||||
order = 3,
|
||||
items = listOf(
|
||||
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1),
|
||||
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2),
|
||||
),
|
||||
),
|
||||
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen
|
||||
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch.
|
||||
)
|
||||
|
||||
fun findBlock(blockDefinitionId: String): BlockDefinition? =
|
||||
blocks.firstOrNull { it.id == blockDefinitionId }
|
||||
}
|
||||
|
||||
data class BlockDefinition(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val order: Int,
|
||||
val items: List<CheckItemDefinition>,
|
||||
)
|
||||
|
||||
data class CheckItemDefinition(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val order: Int,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
/**
|
||||
* Zustand einer einzelnen Checkbox innerhalb eines Dokumentationsblocks.
|
||||
*
|
||||
* [label] stammt aus dem zum Erstellungszeitpunkt der Dokumentation gültigen Snapshot
|
||||
* (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.
|
||||
*/
|
||||
data class CheckItem(
|
||||
val checkItemStateId: String,
|
||||
val definitionId: String,
|
||||
val label: String,
|
||||
val order: Int,
|
||||
val checked: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Eine vollständige Wunddokumentation inklusive aller Blöcke, Fotos und Unterschriften.
|
||||
*
|
||||
* [continuedFromDocumentationId] verweist auf die Vorgänger-Dokumentation, falls diese
|
||||
* Dokumentation durch "Fortsetzen" entstanden ist (Architektur-Dokument Abschnitt 6) – rein
|
||||
* informatives Metadatum, keine fachliche Pflichtlogik in V1.
|
||||
*
|
||||
* [catalogVersion] ist ebenfalls rein informativ (z. B. für spätere Auswertungen). Die
|
||||
* korrekte historische Darstellung hängt NICHT von diesem Feld ab, sondern ausschließlich von
|
||||
* den Label-/Titel-Snapshots in [DocumentationBlock]/[CheckItem] (Abschnitt 2.4).
|
||||
*/
|
||||
data class Documentation(
|
||||
val id: String,
|
||||
val patientId: String,
|
||||
val status: DocumentationStatus,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val continuedFromDocumentationId: String?,
|
||||
val catalogVersion: Int,
|
||||
val blocks: List<DocumentationBlock>,
|
||||
val photos: List<Photo>,
|
||||
val signatures: List<Signature>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Schlanke Projektion für Listenansichten (Startscreen), ohne die vollständigen Blöcke zu
|
||||
* laden – vermeidet unnötig teure Queries, wenn nur Übersichtsdaten benötigt werden.
|
||||
*/
|
||||
data class DocumentationSummary(
|
||||
val id: String,
|
||||
val patientId: String,
|
||||
val patientFullName: String,
|
||||
val status: DocumentationStatus,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
|
||||
|
||||
/**
|
||||
* Ein einzelner Dokumentationsblock innerhalb einer Wunddokumentation (ca. 20 pro
|
||||
* Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht
|
||||
* aus einer Laufzeit-Auflösung des Katalogs.
|
||||
*
|
||||
* [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.
|
||||
*/
|
||||
data class DocumentationBlock(
|
||||
val blockStateId: String,
|
||||
val definitionId: String,
|
||||
val title: String,
|
||||
val order: Int,
|
||||
val items: List<CheckItem>,
|
||||
val freeText: String,
|
||||
) {
|
||||
val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
/**
|
||||
* Status einer Wunddokumentation.
|
||||
*
|
||||
* Bewusst ein einfaches `enum class` statt `sealed interface`/`sealed class`: Die Zustände
|
||||
* tragen aktuell keine zustandsspezifischen Zusatzdaten, sondern stellen nur eine endliche
|
||||
* Auswahl dar. `sealed interface` würde hier keinen Vorteil bringen und nur unnötige
|
||||
* Komplexität hinzufügen. Eine Erweiterung, z. B. um CANCELLED, ist mit einem Enum genauso
|
||||
* einfach möglich. Siehe Architektur-Dokument Abschnitt 2.5.
|
||||
*/
|
||||
enum class DocumentationStatus {
|
||||
IN_PROGRESS,
|
||||
COMPLETED,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Fachliches Patientenmodell. Bewusst unabhängig von Room- und Compose-Typen, damit sich die
|
||||
* Persistenz (Room-Entity) oder die UI unabhängig voneinander weiterentwickeln können.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
data class Patient(
|
||||
val id: String,
|
||||
val firstName: String,
|
||||
val lastName: String,
|
||||
val birthDate: LocalDate,
|
||||
val address: String,
|
||||
val phone1: String,
|
||||
val phone2: String?,
|
||||
val phone3: String?,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
) {
|
||||
val fullName: String get() = "$firstName $lastName"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
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
|
||||
* 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 lastDocumentationAt: Instant?,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Ein an eine Wunddokumentation angehängtes Foto.
|
||||
*
|
||||
* [fileName] ist ein relativer Pfad innerhalb des privaten internen App-Speichers
|
||||
* (context.filesDir/photos/...), kein absoluter Pfad und keine Galerie-URI – die
|
||||
* ausgewählten Bilder werden beim Import dauerhaft in den App-Speicher kopiert
|
||||
* (Architektur-Dokument Abschnitt 11).
|
||||
*/
|
||||
data class Photo(
|
||||
val id: String,
|
||||
val documentationId: String,
|
||||
val fileName: String,
|
||||
val orderIndex: Int,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.medapp.wunddoku.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Eine digitale Unterschrift zu einer Wunddokumentation.
|
||||
*
|
||||
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später
|
||||
* mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
|
||||
* (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen
|
||||
* App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11.
|
||||
*/
|
||||
data class Signature(
|
||||
val id: String,
|
||||
val documentationId: String,
|
||||
val signerName: String?,
|
||||
val fileName: String,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.medapp.wunddoku.domain.usecase
|
||||
|
||||
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||
|
||||
/**
|
||||
* Zentrale, einzige Definition davon, wann ein Dokumentationsblock als "bearbeitet" gilt.
|
||||
*
|
||||
* Diese Regel wird an zwei Stellen verwendet, die zwingend dieselbe Definition teilen müssen:
|
||||
* - UI: Fortschrittsanzeige, welche Blöcke bereits ausgefüllt wurden
|
||||
* - PDF-Export: nur bearbeitete Blöcke werden ins PDF aufgenommen (Aufgabenstellung Abschnitt 12)
|
||||
*
|
||||
* Bewusst als eigenständiges Objekt statt als private Logik in ViewModel/Exporter, damit beide
|
||||
* Stellen garantiert dasselbe Ergebnis liefern und die Regel an einer einzigen Stelle geändert
|
||||
* werden kann, falls sich die fachliche Definition von "bearbeitet" später ändert.
|
||||
*/
|
||||
object BlockEditedPolicy {
|
||||
|
||||
fun isEdited(block: DocumentationBlock): Boolean {
|
||||
val hasFreeText = block.freeText.isNotBlank()
|
||||
val hasCheckedItem = block.items.any { it.checked }
|
||||
return hasFreeText || hasCheckedItem
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.medapp.wunddoku.media
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Verwaltet die physischen Bilddateien im internen App-Speicher (context.filesDir/photos/).
|
||||
*
|
||||
* WICHTIG (Phase-Hinweis): Diese Implementierung kopiert Bilder in Phase 2 zunächst
|
||||
* unverändert (1:1-Kopie der ausgewählten Datei). Downsampling großer Smartphone-Fotos und
|
||||
* EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der
|
||||
* Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten,
|
||||
* dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern.
|
||||
*
|
||||
* Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung
|
||||
* einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21).
|
||||
*/
|
||||
interface PhotoStorage {
|
||||
/** Kopiert das Bild von [sourceUri] in den internen Speicher und liefert den relativen Dateinamen. */
|
||||
suspend fun copyFrom(sourceUri: Uri): String
|
||||
|
||||
/**
|
||||
* Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei und liefert
|
||||
* deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue
|
||||
* DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das
|
||||
* Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen.
|
||||
*/
|
||||
suspend fun duplicate(fileName: String): String
|
||||
|
||||
/** Löscht die physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
|
||||
suspend fun delete(fileName: String)
|
||||
|
||||
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen (z. B. zur Anzeige). */
|
||||
fun resolve(fileName: String): File
|
||||
}
|
||||
|
||||
class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
|
||||
private val photosDir: File by lazy {
|
||||
File(context.filesDir, "photos").apply { mkdirs() }
|
||||
}
|
||||
|
||||
override suspend fun copyFrom(sourceUri: Uri): String {
|
||||
val fileName = "${UUID.randomUUID()}.jpg"
|
||||
val targetFile = File(photosDir, fileName)
|
||||
context.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
targetFile.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri")
|
||||
return fileName
|
||||
}
|
||||
|
||||
override suspend fun duplicate(fileName: String): String {
|
||||
val newFileName = "${UUID.randomUUID()}.jpg"
|
||||
resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false)
|
||||
return newFileName
|
||||
}
|
||||
|
||||
override suspend fun delete(fileName: String) {
|
||||
resolve(fileName).delete()
|
||||
}
|
||||
|
||||
override fun resolve(fileName: String): File = File(photosDir, fileName)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.medapp.wunddoku.signature
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Verwaltet die physischen Signatur-PNGs im internen App-Speicher
|
||||
* (context.filesDir/signatures/). Das eigentliche Zeichnen (SignaturePad-Composable) folgt in
|
||||
* Phase 4; diese Klasse übernimmt nur die Dateiverwaltung, damit DocumentationRepository
|
||||
* bereits jetzt gegen eine stabile Schnittstelle arbeiten kann.
|
||||
*/
|
||||
interface SignatureFileStorage {
|
||||
suspend fun save(bitmap: Bitmap): String
|
||||
suspend fun delete(fileName: String)
|
||||
fun resolve(fileName: String): File
|
||||
}
|
||||
|
||||
class InternalSignatureFileStorage(private val context: Context) : SignatureFileStorage {
|
||||
|
||||
private val signaturesDir: File by lazy {
|
||||
File(context.filesDir, "signatures").apply { mkdirs() }
|
||||
}
|
||||
|
||||
override suspend fun save(bitmap: Bitmap): String {
|
||||
val fileName = "${UUID.randomUUID()}.png"
|
||||
FileOutputStream(File(signaturesDir, fileName)).use { out ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
override suspend fun delete(fileName: String) {
|
||||
resolve(fileName).delete()
|
||||
}
|
||||
|
||||
override fun resolve(fileName: String): File = File(signaturesDir, fileName)
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
import de.medapp.wunddoku.domain.model.CheckItem
|
||||
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||
|
||||
/**
|
||||
* Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2,
|
||||
* Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und
|
||||
* Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen
|
||||
* Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentationEditScreen(
|
||||
onFinished: () -> Unit,
|
||||
viewModel: DocumentationEditViewModel = viewModel(factory = DocumentationEditViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
||||
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument
|
||||
// Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche
|
||||
// Absicherung für den häufigen Fall.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
viewModel.flushPendingChanges()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (uiState.blockCount > 0) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.documentation_edit_position,
|
||||
uiState.currentBlockIndex + 1,
|
||||
uiState.blockCount,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die
|
||||
// Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave
|
||||
// gesichert, kein expliziter Speichern-Button nötig).
|
||||
Button(onClick = onFinished) { Text("Fertig") }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when {
|
||||
uiState.isLoading -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
uiState.error != null -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||
) { Text(uiState.error!!, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
else -> {
|
||||
val documentation = uiState.documentation
|
||||
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex)
|
||||
if (documentation == null || currentBlock == null) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||
Text("Diese Dokumentation enthält keine Blöcke.")
|
||||
}
|
||||
} else {
|
||||
DocumentationBlockContent(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
block = currentBlock,
|
||||
onCheckedChanged = viewModel::onCheckedChanged,
|
||||
onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) },
|
||||
canGoBack = uiState.currentBlockIndex > 0,
|
||||
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
|
||||
onPrevious = viewModel::onPreviousBlock,
|
||||
onNext = viewModel::onNextBlock,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentationBlockContent(
|
||||
modifier: Modifier = Modifier,
|
||||
block: DocumentationBlock,
|
||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||
onFreeTextChanged: (String) -> Unit,
|
||||
canGoBack: Boolean,
|
||||
canGoNext: Boolean,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
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,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
|
||||
Text(stringResource(R.string.documentation_edit_previous))
|
||||
}
|
||||
Button(onClick = onNext, enabled = canGoNext) {
|
||||
Text(stringResource(R.string.documentation_edit_next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckItemRow(
|
||||
item: CheckItem,
|
||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
|
||||
)
|
||||
Text(item.label)
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.navigation.toRoute
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.model.Documentation
|
||||
import de.medapp.wunddoku.ui.navigation.Screen
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DocumentationEditUiState(
|
||||
val documentation: Documentation? = null,
|
||||
val currentBlockIndex: Int = 0,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält den kompletten Bearbeitungszustand einer Wunddokumentation und setzt die Autosave-
|
||||
* Strategie aus dem Architektur-Dokument (Abschnitt 9) um:
|
||||
* - Checkbox-Änderungen: sofort persistiert
|
||||
* - Freitext-Änderungen: UI sofort aktualisiert, Persistierung über 300ms Debounce
|
||||
*
|
||||
* WICHTIG (siehe Architektur-Dokument Abschnitt 9.2): `onCleared()` wird bewusst NICHT als
|
||||
* Garantie für das Flushen ausstehender Änderungen verwendet, da Android dessen Aufruf bei
|
||||
* einem harten Prozessabbruch nicht garantiert. [flushPendingChanges] wird stattdessen vom
|
||||
* Screen aus einem `Lifecycle.Event.ON_STOP`-Observer aufgerufen – das deckt den häufigen Fall
|
||||
* (App gewechselt, Bildschirm gesperrt) ab, ist aber ausdrücklich keine Garantie für den
|
||||
* seltenen Fall eines sofortigen Systemkills.
|
||||
*/
|
||||
class DocumentationEditViewModel(
|
||||
private val documentationId: String,
|
||||
private val repository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DocumentationEditUiState())
|
||||
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val freeTextChanges = MutableSharedFlow<Pair<String, String>>(extraBufferCapacity = 64)
|
||||
|
||||
/** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */
|
||||
private val pendingFreeText = mutableMapOf<String, String>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loadDocumentation() }
|
||||
observeFreeTextChanges()
|
||||
}
|
||||
|
||||
private suspend fun loadDocumentation() {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
val documentation = repository.getFullDocumentation(documentationId)
|
||||
_uiState.update {
|
||||
if (documentation != null) {
|
||||
it.copy(documentation = documentation, isLoading = false)
|
||||
} else {
|
||||
it.copy(isLoading = false, error = "Dokumentation nicht gefunden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeFreeTextChanges() {
|
||||
viewModelScope.launch {
|
||||
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) ->
|
||||
persistFreeText(blockStateId, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistFreeText(blockStateId: String, text: String) {
|
||||
repository.updateFreeText(blockStateId, text)
|
||||
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
|
||||
if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId)
|
||||
}
|
||||
|
||||
fun onFreeTextChanged(blockStateId: String, text: String) {
|
||||
updateLocalFreeText(blockStateId, text)
|
||||
pendingFreeText[blockStateId] = text
|
||||
freeTextChanges.tryEmit(blockStateId to text)
|
||||
}
|
||||
|
||||
private fun updateLocalFreeText(blockStateId: 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
|
||||
}
|
||||
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
||||
}
|
||||
}
|
||||
|
||||
fun onCheckedChanged(checkItemStateId: String, checked: Boolean) {
|
||||
// 1) UI sofort aktualisieren
|
||||
_uiState.update { state ->
|
||||
val documentation = state.documentation ?: return@update state
|
||||
val updatedBlocks = documentation.blocks.map { block ->
|
||||
val updatedItems = block.items.map { item ->
|
||||
if (item.checkItemStateId == checkItemStateId) item.copy(checked = checked) else item
|
||||
}
|
||||
block.copy(items = updatedItems)
|
||||
}
|
||||
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
||||
}
|
||||
// 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE)
|
||||
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
|
||||
}
|
||||
|
||||
fun onNextBlock() {
|
||||
_uiState.update { state ->
|
||||
val lastIndex = state.blockCount - 1
|
||||
if (lastIndex < 0) return@update state
|
||||
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
|
||||
}
|
||||
}
|
||||
|
||||
fun onPreviousBlock() {
|
||||
_uiState.update { state ->
|
||||
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
|
||||
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
|
||||
* hartem Prozessabbruch.
|
||||
*/
|
||||
fun flushPendingChanges() {
|
||||
if (pendingFreeText.isEmpty()) return
|
||||
val toFlush = pendingFreeText.toMap()
|
||||
pendingFreeText.clear()
|
||||
viewModelScope.launch {
|
||||
toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Debounce-Fenster für Freitext-Autosave. Siehe Architektur-Dokument Abschnitt 9.3:
|
||||
* bewusst 300ms statt 500ms, da bei dieser kleinen lokalen SQLite-Datenbank Robustheit
|
||||
* gegenüber reiner Schreibersparnis priorisiert werden darf. */
|
||||
private const val FREE_TEXT_DEBOUNCE_MS = 300L
|
||||
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
val savedStateHandle = createSavedStateHandle()
|
||||
val route = savedStateHandle.toRoute<Screen.DocumentationEdit>()
|
||||
DocumentationEditViewModel(
|
||||
documentationId = route.documentationId,
|
||||
repository = container.documentationRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package de.medapp.wunddoku.ui.documentation.list
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
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
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.ZoneId
|
||||
|
||||
private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
|
||||
/**
|
||||
* Startscreen der App (Aufgabenstellung Abschnitt 5): Liste aller Wunddokumentationen,
|
||||
* durchsuchbar, mit den Aktionen Bearbeiten/Fortsetzen/Löschen (mit Bestätigung).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentationListScreen(
|
||||
onOpenDocumentation: (documentationId: String) -> Unit,
|
||||
onNewDocumentation: () -> Unit,
|
||||
onOpenPatients: () -> Unit,
|
||||
viewModel: DocumentationListViewModel = viewModel(factory = DocumentationListViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.documentation_list_title)) },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenPatients) {
|
||||
Icon(Icons.Filled.People, contentDescription = stringResource(R.string.patient_list_title))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(onClick = onNewDocumentation) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text(stringResource(R.string.documentation_list_new))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChanged,
|
||||
label = { Text(stringResource(R.string.documentation_list_search_hint)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
|
||||
if (uiState.items.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.documentation_list_empty),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(uiState.items, key = { it.id }) { item ->
|
||||
DocumentationListItemCard(
|
||||
item = item,
|
||||
onOpen = { onOpenDocumentation(item.id) },
|
||||
onContinue = {
|
||||
viewModel.continueDocumentation(item.id) { newId -> onOpenDocumentation(newId) }
|
||||
},
|
||||
onDeleteRequested = { pendingDeleteId = item.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deleteId = pendingDeleteId
|
||||
if (deleteId != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDeleteId = null },
|
||||
title = { Text(stringResource(R.string.documentation_delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.documentation_delete_confirm_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.deleteDocumentation(deleteId)
|
||||
pendingDeleteId = null
|
||||
}) { Text(stringResource(R.string.action_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DocumentationListItemCard(
|
||||
item: DocumentationSummary,
|
||||
onOpen: () -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
onDeleteRequested: () -> Unit,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text(item.patientFullName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Erstellt: ${item.createdAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (item.updatedAt != item.createdAt) {
|
||||
Text(
|
||||
"Geändert: ${item.updatedAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (item.status == DocumentationStatus.COMPLETED) {
|
||||
Text("Abgeschlossen", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Text("⋮")
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_edit)) },
|
||||
onClick = { menuExpanded = false; onOpen() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_continue)) },
|
||||
onClick = { menuExpanded = false; onContinue() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_delete)) },
|
||||
onClick = { menuExpanded = false; onDeleteRequested() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package de.medapp.wunddoku.ui.documentation.list
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DocumentationListUiState(
|
||||
val items: List<DocumentationSummary> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Kotlin-Hinweis zu `StateFlow`/`Flow`/`flatMapLatest`: [searchQuery] ist der Eingabetext, aus
|
||||
* dem wir per `flatMapLatest` jeweils den passenden Datenbank-`Flow` ableiten (entweder "alle"
|
||||
* oder "gefiltert nach Suchtext") – ändert sich der Suchtext erneut, während die vorherige
|
||||
* Datenbankabfrage noch läuft, bricht `flatMapLatest` diese automatisch ab und wechselt zur
|
||||
* neuen. Das verhindert veraltete Suchergebnisse ohne manuelles Abbrechen von Coroutines.
|
||||
*/
|
||||
class DocumentationListViewModel(
|
||||
private val repository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val items = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) repository.observeAll() else repository.search(query)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<DocumentationListUiState> = combine(items, searchQuery) { list, query ->
|
||||
DocumentationListUiState(items = list, searchQuery = query)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = DocumentationListUiState(),
|
||||
)
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
fun deleteDocumentation(documentationId: String) {
|
||||
viewModelScope.launch { repository.delete(documentationId) }
|
||||
}
|
||||
|
||||
fun continueDocumentation(documentationId: String, onCreated: (newDocumentationId: String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val newDocumentation = repository.continueDocumentation(documentationId)
|
||||
onCreated(newDocumentation.id)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
DocumentationListViewModel(container.documentationRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.medapp.wunddoku.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen
|
||||
import de.medapp.wunddoku.ui.documentation.list.DocumentationListScreen
|
||||
import de.medapp.wunddoku.ui.patients.form.PatientFormScreen
|
||||
import de.medapp.wunddoku.ui.patients.list.PatientListScreen
|
||||
|
||||
/**
|
||||
* Navigations-Graph gemäß Architektur-Dokument Abschnitt 8:
|
||||
*
|
||||
* DocumentationList -> PatientForm(null) -> DocumentationEdit(neue Id)
|
||||
* DocumentationList -> PatientList -> PatientForm(id) / DocumentationEdit (bearbeiten/fortsetzen)
|
||||
* DocumentationEdit -> zurück zu DocumentationList (nach Abschluss/PDF)
|
||||
*/
|
||||
@Composable
|
||||
fun WunddokuNavGraph() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = Screen.DocumentationList) {
|
||||
|
||||
composable<Screen.DocumentationList> {
|
||||
DocumentationListScreen(
|
||||
onOpenDocumentation = { id -> navController.navigate(Screen.DocumentationEdit(id)) },
|
||||
onNewDocumentation = {
|
||||
navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = true))
|
||||
},
|
||||
onOpenPatients = { navController.navigate(Screen.PatientList) },
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.PatientList> {
|
||||
PatientListScreen(
|
||||
onEditPatient = { id ->
|
||||
navController.navigate(Screen.PatientForm(patientId = id, startsNewDocumentation = false))
|
||||
},
|
||||
onNewPatient = {
|
||||
navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = false))
|
||||
},
|
||||
onOpenDocumentation = { documentationId -> navController.navigate(Screen.DocumentationEdit(documentationId)) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.PatientForm> {
|
||||
PatientFormScreen(
|
||||
onPatientSavedOnly = { navController.popBackStack() },
|
||||
onDocumentationCreated = { documentationId ->
|
||||
// Das Formular wird aus dem Back-Stack entfernt, damit "Zurück" aus der
|
||||
// Dokumentation nicht wieder im Formular landet.
|
||||
navController.navigate(Screen.DocumentationEdit(documentationId)) {
|
||||
popUpTo<Screen.DocumentationList> { inclusive = false }
|
||||
}
|
||||
},
|
||||
onCancel = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.DocumentationEdit> {
|
||||
DocumentationEditScreen(
|
||||
onFinished = {
|
||||
navController.navigate(Screen.DocumentationList) {
|
||||
popUpTo<Screen.DocumentationList> { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.medapp.wunddoku.ui.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Typsichere Navigations-Routen (aktueller Standard von Navigation Compose seit 2.8, statt
|
||||
* loser String-Routen mit manuellem Argument-Parsing). Tippfehler in Routen-Namen fallen damit
|
||||
* bereits beim Kompilieren auf statt erst zur Laufzeit.
|
||||
*
|
||||
* Kotlin-Hinweis: `sealed interface` fasst die möglichen Bildschirme zu einer geschlossenen,
|
||||
* dem Compiler bekannten Menge zusammen (siehe auch Erläuterung bei DocumentationStatus, wo
|
||||
* bewusst KEIN sealed interface verwendet wird, weil dort keine zustandsspezifischen Daten
|
||||
* nötig sind) – hier tragen die Varianten dagegen unterschiedliche, jeweils eigene Argumente
|
||||
* (z. B. `documentationId`), was `sealed interface` hier zur passenden Wahl macht.
|
||||
* `@Serializable` (kotlinx.serialization) ist die Voraussetzung dafür, dass Navigation Compose
|
||||
* eine Route-Instanz automatisch in einen Navigations-Zustand kodieren/dekodieren kann.
|
||||
*/
|
||||
sealed interface Screen {
|
||||
|
||||
@Serializable
|
||||
data object DocumentationList : Screen
|
||||
|
||||
@Serializable
|
||||
data object PatientList : Screen
|
||||
|
||||
/**
|
||||
* [startsNewDocumentation] unterscheidet zwei fachlich unterschiedliche Abläufe, die sich
|
||||
* dasselbe Formular teilen (Aufgabenstellung Abschnitt 7 Schritt 1 vs. Abschnitt 6
|
||||
* "Patient bearbeiten"):
|
||||
* - `true`: Formular ist Schritt 1 einer neuen Wunddokumentation ("Neu" auf der
|
||||
* Dokumentationsliste). Nach dem Speichern/Duplikat-Abgleich wird direkt eine neue,
|
||||
* leere Dokumentation angelegt und geöffnet.
|
||||
* - `false`: reines Bearbeiten der Patienten-Stammdaten (aus der Patientenliste). Nach dem
|
||||
* Speichern geht es zurück zur Patientenliste, ohne eine Dokumentation anzulegen.
|
||||
*/
|
||||
@Serializable
|
||||
data class PatientForm(val patientId: String? = null, val startsNewDocumentation: Boolean = false) : Screen
|
||||
|
||||
@Serializable
|
||||
data class DocumentationEdit(val documentationId: String) : Screen
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package de.medapp.wunddoku.ui.patients.form
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
|
||||
/**
|
||||
* 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].
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PatientFormScreen(
|
||||
onPatientSavedOnly: () -> Unit,
|
||||
onDocumentationCreated: (documentationId: String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
viewModel: PatientFormViewModel = viewModel(factory = PatientFormViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(uiState.patientSaved) {
|
||||
if (uiState.patientSaved) onPatientSavedOnly()
|
||||
}
|
||||
LaunchedEffect(uiState.createdDocumentationId) {
|
||||
uiState.createdDocumentationId?.let { onDocumentationCreated(it) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (uiState.isEditing) R.string.patient_form_title_edit else R.string.patient_form_title_new
|
||||
)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (uiState.isLoading) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = uiState.firstName,
|
||||
onValueChange = viewModel::onFirstNameChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_first_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.lastName,
|
||||
onValueChange = viewModel::onLastNameChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_last_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.birthDateText,
|
||||
onValueChange = viewModel::onBirthDateTextChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_birth_date)) },
|
||||
placeholder = { Text("JJJJ-MM-TT") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.address,
|
||||
onValueChange = viewModel::onAddressChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_address)) },
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
uiState.validationError?.let { error ->
|
||||
Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.patient_form_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val duplicate = uiState.duplicateWarning
|
||||
if (duplicate != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissDuplicateWarning,
|
||||
title = { Text("Patient existiert bereits") },
|
||||
text = {
|
||||
Text(
|
||||
"Es existiert bereits ein Patient mit diesem Namen und Geburtsdatum " +
|
||||
"(${duplicate.fullName}, ${duplicate.birthDate}). Möchtest du diesen " +
|
||||
"vorhandenen Patienten verwenden, oder trotzdem einen neuen Datensatz anlegen?"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::useExistingPatient) {
|
||||
Text("Vorhandenen verwenden")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::createAnyway) {
|
||||
Text("Trotzdem neu anlegen")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package de.medapp.wunddoku.ui.patients.form
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.navigation.toRoute
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.model.Patient
|
||||
import de.medapp.wunddoku.ui.navigation.Screen
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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 = "",
|
||||
val phone1: String = "",
|
||||
val phone2: String = "",
|
||||
val phone3: String = "",
|
||||
val validationError: String? = null,
|
||||
/** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */
|
||||
val duplicateWarning: Patient? = null,
|
||||
/** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */
|
||||
val patientSaved: Boolean = false,
|
||||
/** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */
|
||||
val createdDocumentationId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab
|
||||
* (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6).
|
||||
*/
|
||||
class PatientFormViewModel(
|
||||
private val patientId: String?,
|
||||
private val startsNewDocumentation: Boolean,
|
||||
private val repository: PatientRepository,
|
||||
private val documentationRepository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null))
|
||||
val uiState: StateFlow<PatientFormUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
if (patientId != null) {
|
||||
viewModelScope.launch {
|
||||
val patient = repository.getById(patientId)
|
||||
_uiState.update {
|
||||
if (patient != null) {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
firstName = patient.firstName,
|
||||
lastName = patient.lastName,
|
||||
birthDateText = patient.birthDate.toString(),
|
||||
address = patient.address,
|
||||
phone1 = patient.phone1,
|
||||
phone2 = patient.phone2.orEmpty(),
|
||||
phone3 = patient.phone3.orEmpty(),
|
||||
)
|
||||
} else {
|
||||
it.copy(isLoading = false, validationError = "Patient nicht gefunden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
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 dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
|
||||
|
||||
/** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */
|
||||
fun useExistingPatient() {
|
||||
val existing = _uiState.value.duplicateWarning ?: return
|
||||
_uiState.update { it.copy(duplicateWarning = null) }
|
||||
viewModelScope.launch { finish(existing.id) }
|
||||
}
|
||||
|
||||
/** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */
|
||||
fun createAnyway() {
|
||||
_uiState.update { it.copy(duplicateWarning = null) }
|
||||
save(skipDuplicateCheck = true)
|
||||
}
|
||||
|
||||
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.") }
|
||||
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.") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
if (!state.isEditing && !skipDuplicateCheck) {
|
||||
val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate)
|
||||
if (existing != null) {
|
||||
_uiState.update { it.copy(duplicateWarning = existing, validationError = null) }
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val patient = Patient(
|
||||
id = patientId ?: repository.newId(),
|
||||
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 },
|
||||
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
val saved = repository.upsert(patient)
|
||||
_uiState.update { it.copy(validationError = null) }
|
||||
finish(saved.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
private suspend fun finish(resolvedPatientId: String) {
|
||||
if (startsNewDocumentation) {
|
||||
val documentation = documentationRepository.createEmpty(resolvedPatientId)
|
||||
_uiState.update { it.copy(createdDocumentationId = documentation.id) }
|
||||
} else {
|
||||
_uiState.update { it.copy(patientSaved = true) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
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(
|
||||
patientId = route.patientId,
|
||||
startsNewDocumentation = route.startsNewDocumentation,
|
||||
repository = container.patientRepository,
|
||||
documentationRepository = container.documentationRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package de.medapp.wunddoku.ui.patients.list
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
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.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
import de.medapp.wunddoku.domain.model.PatientListItem
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.ZoneId
|
||||
|
||||
private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
|
||||
/** Separate Patientenansicht (Aufgabenstellung Abschnitt 6). */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PatientListScreen(
|
||||
onEditPatient: (patientId: String) -> Unit,
|
||||
onNewPatient: () -> Unit,
|
||||
onOpenDocumentation: (documentationId: String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: PatientListViewModel = viewModel(factory = PatientListViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.patient_list_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(onClick = onNewPatient) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text(stringResource(R.string.patient_list_new))
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChanged,
|
||||
label = { Text(stringResource(R.string.patient_list_search_hint)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
|
||||
if (uiState.items.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.patient_list_empty),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(uiState.items, key = { it.id }) { item ->
|
||||
PatientListItemCard(
|
||||
item = item,
|
||||
onEdit = { onEditPatient(item.id) },
|
||||
onDeleteRequested = { viewModel.requestDelete(item.id) },
|
||||
onNewDocumentation = {
|
||||
viewModel.createDocumentation(item.id) { docId -> onOpenDocumentation(docId) }
|
||||
},
|
||||
onContinueDocumentation = {
|
||||
viewModel.continueDocumentation(item.id) { docId ->
|
||||
if (docId != null) {
|
||||
onOpenDocumentation(docId)
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
"Für diesen Patienten existiert noch keine Dokumentation zum Fortsetzen."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.deleteBlockedForPatientId != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissDeleteBlockedDialog,
|
||||
title = { Text(stringResource(R.string.patient_delete_blocked_title)) },
|
||||
text = { Text(stringResource(R.string.patient_delete_blocked_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::dismissDeleteBlockedDialog) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PatientListItemCard(
|
||||
item: PatientListItem,
|
||||
onEdit: () -> Unit,
|
||||
onDeleteRequested: () -> Unit,
|
||||
onNewDocumentation: () -> Unit,
|
||||
onContinueDocumentation: () -> Unit,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(onClick = onEdit, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text(item.fullName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Geburtsdatum: ${item.birthDate.format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(item.address, style = MaterialTheme.typography.bodySmall)
|
||||
val lastDoc = item.lastDocumentationAt
|
||||
if (lastDoc != null) {
|
||||
Text(
|
||||
"Letzte Dokumentation: ${lastDoc.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Text("⋮")
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_edit)) },
|
||||
onClick = { menuExpanded = false; onEdit() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Neue Dokumentation") },
|
||||
onClick = { menuExpanded = false; onNewDocumentation() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_continue)) },
|
||||
onClick = { menuExpanded = false; onContinueDocumentation() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_delete)) },
|
||||
onClick = { menuExpanded = false; onDeleteRequested() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.medapp.wunddoku.ui.patients.list
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
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.PatientListItem
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PatientListUiState(
|
||||
val items: List<PatientListItem> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
/** != null, solange der Bestätigungsdialog "Löschen nicht möglich" angezeigt werden soll. */
|
||||
val deleteBlockedForPatientId: String? = null,
|
||||
)
|
||||
|
||||
class PatientListViewModel(
|
||||
private val repository: PatientRepository,
|
||||
private val documentationRepository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
private val deleteBlockedForPatientId = MutableStateFlow<String?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val items = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) repository.observeAll() else repository.search(query)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<PatientListUiState> =
|
||||
combine(items, searchQuery, deleteBlockedForPatientId) { list, query, blockedId ->
|
||||
PatientListUiState(items = list, searchQuery = query, deleteBlockedForPatientId = blockedId)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = PatientListUiState(),
|
||||
)
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
/**
|
||||
* Löschversuch (Aufgabenstellung Abschnitt 6/20): Prüft zuerst, ob noch Dokumentationen zu
|
||||
* diesem Patienten existieren, statt die Löschung blind zu versuchen und eine rohe
|
||||
* Datenbank-Exception (ON DELETE RESTRICT) abfangen zu müssen. So kann eine verständliche
|
||||
* Meldung angezeigt werden, statt eines technischen Fehlers.
|
||||
*/
|
||||
fun requestDelete(patientId: String) {
|
||||
viewModelScope.launch {
|
||||
if (repository.hasDocumentations(patientId)) {
|
||||
deleteBlockedForPatientId.update { patientId }
|
||||
} else {
|
||||
repository.delete(patientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissDeleteBlockedDialog() {
|
||||
deleteBlockedForPatientId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* "neue Wunddokumentation für Patient erstellen" (Aufgabenstellung Abschnitt 6): Der
|
||||
* Patient existiert bereits, es ist deshalb KEIN Stammdatenformular nötig – anders als beim
|
||||
* Weg über die Dokumentationsliste ("Neu"), wo der Patient ggf. erst angelegt wird.
|
||||
*/
|
||||
fun createDocumentation(patientId: String, onCreated: (documentationId: String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val documentation = documentationRepository.createEmpty(patientId)
|
||||
onCreated(documentation.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Behandlung/Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 6). Liefert `null` an
|
||||
* [onCreated], falls der Patient noch keine Dokumentation besitzt – die UI zeigt in diesem
|
||||
* Fall einen verständlichen Hinweis statt eines technischen Fehlers.
|
||||
*/
|
||||
fun continueDocumentation(patientId: String, onCreated: (documentationId: String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val documentation = documentationRepository.continueLatestForPatient(patientId)
|
||||
onCreated(documentation?.id)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
PatientListViewModel(container.patientRepository, container.documentationRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.medapp.wunddoku.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val LightColors = lightColorScheme()
|
||||
private val DarkColors = darkColorScheme()
|
||||
|
||||
/**
|
||||
* Bewusst schlichtes Material-3-Theme ohne individuelle Markenfarben – für dieses Projekt gab
|
||||
* es keine gestalterische Vorgabe. Dynamic Color (aus dem Systemwallpaper abgeleitete Farben,
|
||||
* ab Android 12) wird verwendet, wo verfügbar, sonst ein neutrales Standard-Farbschema.
|
||||
*/
|
||||
@Composable
|
||||
fun WunddokuTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Schlichtes, neutrales Platzhalter-Icon (Kreuz-Symbol), bis ein echtes Branding-Icon
|
||||
geliefert wird. Als Vektor-Grafik statt PNG, damit kein separates Grafik-Tool nötig ist. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M48,24 H60 V48 H84 V60 H60 V84 H48 V60 H24 V48 H48 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1B5E56</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">VulneraDoc</string>
|
||||
|
||||
<string name="documentation_list_title">Wunddokumentationen</string>
|
||||
<string name="documentation_list_search_hint">Patient oder Geburtsdatum suchen</string>
|
||||
<string name="documentation_list_empty">Noch keine Wunddokumentationen vorhanden.</string>
|
||||
<string name="documentation_list_new">Neue Dokumentation</string>
|
||||
<string name="documentation_action_edit">Bearbeiten</string>
|
||||
<string name="documentation_action_continue">Fortsetzen</string>
|
||||
<string name="documentation_action_delete">Löschen</string>
|
||||
<string name="documentation_delete_confirm_title">Dokumentation löschen?</string>
|
||||
<string name="documentation_delete_confirm_text">Diese Wunddokumentation inklusive aller Fotos und Unterschriften wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.</string>
|
||||
|
||||
<string name="patient_list_title">Patienten</string>
|
||||
<string name="patient_list_search_hint">Name oder Geburtsdatum suchen</string>
|
||||
<string name="patient_list_empty">Noch keine Patienten vorhanden.</string>
|
||||
<string name="patient_list_new">Neuer Patient</string>
|
||||
<string name="patient_delete_blocked_title">Löschen nicht möglich</string>
|
||||
<string name="patient_delete_blocked_text">Für diesen Patienten existieren noch Wunddokumentationen. Diese müssen zuerst gelöscht werden, bevor der Patient gelöscht werden kann.</string>
|
||||
|
||||
<string name="patient_form_title_new">Neuer Patient</string>
|
||||
<string name="patient_form_title_edit">Patient bearbeiten</string>
|
||||
<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_phone1">Telefonnummer 1</string>
|
||||
<string name="patient_form_phone2">Telefonnummer 2 (optional)</string>
|
||||
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
|
||||
<string name="patient_form_save">Speichern</string>
|
||||
|
||||
<string name="documentation_edit_position">%1$d / %2$d</string>
|
||||
<string name="documentation_edit_previous">Zurück</string>
|
||||
<string name="documentation_edit_next">Weiter</string>
|
||||
<string name="documentation_edit_free_text_label">Freitext</string>
|
||||
|
||||
<string name="action_cancel">Abbrechen</string>
|
||||
<string name="action_confirm">Bestätigen</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Reines "Platzhalter"-Theme für das Manifest, bevor Compose übernimmt. Die eigentliche
|
||||
Gestaltung (Farben, Typografie) erfolgt ausschließlich über WunddokuTheme in Compose. -->
|
||||
<style name="Theme.Wunddoku" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
android:allowBackup="false" im Manifest deaktiviert Auto-Backup bereits vollständig.
|
||||
Diese Datei ist eine zusätzliche, explizite Absicherung für Android 12+ (Device-to-Device-
|
||||
Transfer beim Gerätewechsel), damit auch dieser Weg keine Patientendaten überträgt.
|
||||
Siehe Abschnitt "Wichtige Android-spezifische Entscheidungen" der Architektur.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup disableIfNoEncryptionCapability="true">
|
||||
<exclude domain="database" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="sharedpref" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="database" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="sharedpref" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
|
||||
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
|
||||
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
|
||||
intern (Abschnitt "Storage-Strategie" der Architektur, v3).
|
||||
-->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path name="pdf_exports" path="pdf/" />
|
||||
<files-path name="photos" path="photos/" />
|
||||
<files-path name="signatures" path="signatures/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user