diff --git a/.gradle/8.11.1/checksums/checksums.lock b/.gradle/8.11.1/checksums/checksums.lock
new file mode 100644
index 0000000..4522084
Binary files /dev/null and b/.gradle/8.11.1/checksums/checksums.lock differ
diff --git a/.gradle/8.11.1/checksums/md5-checksums.bin b/.gradle/8.11.1/checksums/md5-checksums.bin
new file mode 100644
index 0000000..10c6b8f
Binary files /dev/null and b/.gradle/8.11.1/checksums/md5-checksums.bin differ
diff --git a/.gradle/8.11.1/checksums/sha1-checksums.bin b/.gradle/8.11.1/checksums/sha1-checksums.bin
new file mode 100644
index 0000000..26c3183
Binary files /dev/null and b/.gradle/8.11.1/checksums/sha1-checksums.bin differ
diff --git a/.gradle/8.11.1/executionHistory/executionHistory.bin b/.gradle/8.11.1/executionHistory/executionHistory.bin
new file mode 100644
index 0000000..912e3ab
Binary files /dev/null and b/.gradle/8.11.1/executionHistory/executionHistory.bin differ
diff --git a/.gradle/8.11.1/executionHistory/executionHistory.lock b/.gradle/8.11.1/executionHistory/executionHistory.lock
new file mode 100644
index 0000000..ee4cb4a
Binary files /dev/null and b/.gradle/8.11.1/executionHistory/executionHistory.lock differ
diff --git a/.gradle/8.11.1/fileChanges/last-build.bin b/.gradle/8.11.1/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/.gradle/8.11.1/fileChanges/last-build.bin differ
diff --git a/.gradle/8.11.1/fileHashes/fileHashes.bin b/.gradle/8.11.1/fileHashes/fileHashes.bin
new file mode 100644
index 0000000..d14825b
Binary files /dev/null and b/.gradle/8.11.1/fileHashes/fileHashes.bin differ
diff --git a/.gradle/8.11.1/fileHashes/fileHashes.lock b/.gradle/8.11.1/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..135d546
Binary files /dev/null and b/.gradle/8.11.1/fileHashes/fileHashes.lock differ
diff --git a/.gradle/8.11.1/gc.properties b/.gradle/8.11.1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/.gradle/9.3.0/checksums/checksums.lock b/.gradle/9.3.0/checksums/checksums.lock
new file mode 100644
index 0000000..aaf5492
Binary files /dev/null and b/.gradle/9.3.0/checksums/checksums.lock differ
diff --git a/.gradle/9.3.0/checksums/md5-checksums.bin b/.gradle/9.3.0/checksums/md5-checksums.bin
new file mode 100644
index 0000000..f10dbb7
Binary files /dev/null and b/.gradle/9.3.0/checksums/md5-checksums.bin differ
diff --git a/.gradle/9.3.0/checksums/sha1-checksums.bin b/.gradle/9.3.0/checksums/sha1-checksums.bin
new file mode 100644
index 0000000..a4c65ab
Binary files /dev/null and b/.gradle/9.3.0/checksums/sha1-checksums.bin differ
diff --git a/.gradle/9.3.0/executionHistory/executionHistory.bin b/.gradle/9.3.0/executionHistory/executionHistory.bin
new file mode 100644
index 0000000..741ead9
Binary files /dev/null and b/.gradle/9.3.0/executionHistory/executionHistory.bin differ
diff --git a/.gradle/9.3.0/executionHistory/executionHistory.lock b/.gradle/9.3.0/executionHistory/executionHistory.lock
new file mode 100644
index 0000000..0e4e0b8
Binary files /dev/null and b/.gradle/9.3.0/executionHistory/executionHistory.lock differ
diff --git a/.gradle/9.3.0/fileChanges/last-build.bin b/.gradle/9.3.0/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/.gradle/9.3.0/fileChanges/last-build.bin differ
diff --git a/.gradle/9.3.0/fileHashes/fileHashes.bin b/.gradle/9.3.0/fileHashes/fileHashes.bin
new file mode 100644
index 0000000..c9ca934
Binary files /dev/null and b/.gradle/9.3.0/fileHashes/fileHashes.bin differ
diff --git a/.gradle/9.3.0/fileHashes/fileHashes.lock b/.gradle/9.3.0/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..0ff5efc
Binary files /dev/null and b/.gradle/9.3.0/fileHashes/fileHashes.lock differ
diff --git a/.gradle/9.3.0/gc.properties b/.gradle/9.3.0/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 0000000..9aff831
Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 0000000..f56856d
--- /dev/null
+++ b/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Thu Aug 20 08:33:55 CEST 2026
+gradle.version=8.11.1
diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin
new file mode 100644
index 0000000..c45ea0a
Binary files /dev/null and b/.gradle/buildOutputCleanup/outputFiles.bin differ
diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe
new file mode 100644
index 0000000..cdab173
Binary files /dev/null and b/.gradle/file-system.probe differ
diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..acc2acd
--- /dev/null
+++ b/app/build.gradle.kts
@@ -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")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..600c9bc
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -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").
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a47a0be
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/medapp/wunddoku/MainActivity.kt b/app/src/main/java/de/medapp/wunddoku/MainActivity.kt
new file mode 100644
index 0000000..cdedee9
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/MainActivity.kt
@@ -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()
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/WunddokuApplication.kt b/app/src/main/java/de/medapp/wunddoku/WunddokuApplication.kt
new file mode 100644
index 0000000..8845b12
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/WunddokuApplication.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt
new file mode 100644
index 0000000..275ae25
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/dao/BlockStateDao.kt
@@ -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)
+
+ @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
+
+ @Query("UPDATE block_states SET freeText = :freeText, updatedAt = :updatedAt WHERE id = :id")
+ suspend fun updateFreeText(id: String, freeText: String, updatedAt: Long)
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt
new file mode 100644
index 0000000..92394ba
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/dao/CheckItemStateDao.kt
@@ -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)
+
+ @Query("SELECT * FROM check_item_states WHERE blockStateId = :blockStateId ORDER BY itemOrder")
+ suspend fun getForBlockState(blockStateId: String): List
+
+ @Query("UPDATE check_item_states SET checked = :checked WHERE id = :id")
+ suspend fun setChecked(id: String, checked: Boolean)
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt
new file mode 100644
index 0000000..cddd351
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/dao/DocumentationDao.kt
@@ -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>
+
+ @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>
+
+ @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
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/PatientDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/PatientDao.kt
new file mode 100644
index 0000000..a0b2d83
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/dao/PatientDao.kt
@@ -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>
+
+ @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>
+
+ /**
+ * 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
+}
+
diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/PhotoDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/PhotoDao.kt
new file mode 100644
index 0000000..b3b37ac
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/dao/PhotoDao.kt
@@ -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
+
+ @Query("SELECT COALESCE(MAX(orderIndex), -1) FROM photos WHERE documentationId = :documentationId")
+ suspend fun getMaxOrderIndex(documentationId: String): Int
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt
new file mode 100644
index 0000000..7d5a682
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/dao/SignatureDao.kt
@@ -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
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt
new file mode 100644
index 0000000..71461fa
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/database/AppDatabase.kt
@@ -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 }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt
new file mode 100644
index 0000000..5e476e5
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/BlockStateEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt
new file mode 100644
index 0000000..9494753
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/CheckItemStateEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt
new file mode 100644
index 0000000..f30125c
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/DocumentationEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt
new file mode 100644
index 0000000..d98dd2d
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/PatientEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/PhotoEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/PhotoEntity.kt
new file mode 100644
index 0000000..121c6c1
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/PhotoEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt
new file mode 100644
index 0000000..9520bae
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/SignatureEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/converter/DocumentationStatusConverter.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/converter/DocumentationStatusConverter.kt
new file mode 100644
index 0000000..886e453
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/converter/DocumentationStatusConverter.kt
@@ -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)
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt
new file mode 100644
index 0000000..752aea7
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/entity/relations/DocumentationRelations.kt
@@ -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,
+)
+
+/**
+ * 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,
+ @Relation(
+ parentColumn = "id",
+ entityColumn = "documentationId",
+ )
+ val photos: List,
+ @Relation(
+ parentColumn = "id",
+ entityColumn = "documentationId",
+ )
+ val signatures: List,
+)
+
+/**
+ * Schlanke Relation für Listenansichten: Dokumentation + zugehöriger Patient, ohne Blöcke/
+ * Fotos/Unterschriften zu laden. Wird für die Wunddokumentationsliste (Startscreen) verwendet.
+ */
+data class DocumentationWithPatient(
+ @Embedded val documentation: DocumentationEntity,
+ @Relation(
+ parentColumn = "patientId",
+ entityColumn = "id",
+ )
+ val patient: PatientEntity,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt
new file mode 100644
index 0000000..a4812f1
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/DocumentationMapper.kt
@@ -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),
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt
new file mode 100644
index 0000000..4a3d881
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/mapper/PatientMapper.kt
@@ -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(),
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt
new file mode 100644
index 0000000..30abece
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt
@@ -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> =
+ documentationDao.observeAllWithPatient().map { list -> list.map { it.toSummary() } }
+
+ fun search(query: String): Flow> =
+ 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,
+ )
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt
new file mode 100644
index 0000000..9574b13
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/data/repository/PatientRepository.kt
@@ -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> =
+ patientDao.observeAllWithLastDocumentation().map { list -> list.map { it.toListItem() } }
+
+ fun search(query: String): Flow> =
+ 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) },
+ )
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt
new file mode 100644
index 0000000..315d3a8
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt
@@ -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,
+ )
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/di/ViewModelFactoryHelper.kt b/app/src/main/java/de/medapp/wunddoku/di/ViewModelFactoryHelper.kt
new file mode 100644
index 0000000..5e75094
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/di/ViewModelFactoryHelper.kt
@@ -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
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt
new file mode 100644
index 0000000..33fcc7a
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/catalog/DocumentationCatalog.kt
@@ -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 = 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,
+)
+
+data class CheckItemDefinition(
+ val id: String,
+ val label: String,
+ val order: Int,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt
new file mode 100644
index 0000000..713cf9a
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/CheckItem.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt
new file mode 100644
index 0000000..0c09d97
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Documentation.kt
@@ -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,
+ val photos: List,
+ val signatures: List,
+)
+
+/**
+ * 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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt
new file mode 100644
index 0000000..922f43d
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationBlock.kt
@@ -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,
+ val freeText: String,
+) {
+ val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this)
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationStatus.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationStatus.kt
new file mode 100644
index 0000000..601ed79
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/DocumentationStatus.kt
@@ -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,
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt
new file mode 100644
index 0000000..f71d0c6
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Patient.kt
@@ -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"
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt
new file mode 100644
index 0000000..9ff7370
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/PatientListItem.kt
@@ -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?,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Photo.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Photo.kt
new file mode 100644
index 0000000..f54f620
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Photo.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt
new file mode 100644
index 0000000..b825574
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/model/Signature.kt
@@ -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,
+)
diff --git a/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt
new file mode 100644
index 0000000..eb0ba07
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicy.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt
new file mode 100644
index 0000000..961a38e
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt
@@ -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)
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureFileStorage.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureFileStorage.kt
new file mode 100644
index 0000000..1a9af4a
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureFileStorage.kt
@@ -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)
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt
new file mode 100644
index 0000000..ee370a7
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt
new file mode 100644
index 0000000..f62e5be
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt
@@ -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 = _uiState.asStateFlow()
+
+ private val freeTextChanges = MutableSharedFlow>(extraBufferCapacity = 64)
+
+ /** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */
+ private val pendingFreeText = mutableMapOf()
+
+ 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()
+ DocumentationEditViewModel(
+ documentationId = route.documentationId,
+ repository = container.documentationRepository,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListScreen.kt
new file mode 100644
index 0000000..ed68a57
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListScreen.kt
@@ -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(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() },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListViewModel.kt
new file mode 100644
index 0000000..03d4c47
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/list/DocumentationListViewModel.kt
@@ -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 = 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 = 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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/navigation/NavGraph.kt b/app/src/main/java/de/medapp/wunddoku/ui/navigation/NavGraph.kt
new file mode 100644
index 0000000..5b02e1c
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/navigation/NavGraph.kt
@@ -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 {
+ DocumentationListScreen(
+ onOpenDocumentation = { id -> navController.navigate(Screen.DocumentationEdit(id)) },
+ onNewDocumentation = {
+ navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = true))
+ },
+ onOpenPatients = { navController.navigate(Screen.PatientList) },
+ )
+ }
+
+ composable {
+ 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 {
+ 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 { inclusive = false }
+ }
+ },
+ onCancel = { navController.popBackStack() },
+ )
+ }
+
+ composable {
+ DocumentationEditScreen(
+ onFinished = {
+ navController.navigate(Screen.DocumentationList) {
+ popUpTo { inclusive = true }
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/navigation/Screen.kt b/app/src/main/java/de/medapp/wunddoku/ui/navigation/Screen.kt
new file mode 100644
index 0000000..4bcd252
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/navigation/Screen.kt
@@ -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
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt
new file mode 100644
index 0000000..727711a
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt
@@ -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")
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt
new file mode 100644
index 0000000..8f27c1d
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt
@@ -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 = _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()
+ PatientFormViewModel(
+ patientId = route.patientId,
+ startsNewDocumentation = route.startsNewDocumentation,
+ repository = container.patientRepository,
+ documentationRepository = container.documentationRepository,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt
new file mode 100644
index 0000000..65617f9
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListScreen.kt
@@ -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() },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListViewModel.kt
new file mode 100644
index 0000000..9d054ad
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/list/PatientListViewModel.kt
@@ -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 = 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(null)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val items = searchQuery.flatMapLatest { query ->
+ if (query.isBlank()) repository.observeAll() else repository.search(query)
+ }
+
+ val uiState: StateFlow =
+ 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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/medapp/wunddoku/ui/theme/Theme.kt b/app/src/main/java/de/medapp/wunddoku/ui/theme/Theme.kt
new file mode 100644
index 0000000..f003920
--- /dev/null
+++ b/app/src/main/java/de/medapp/wunddoku/ui/theme/Theme.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..eb598ef
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..40fa44b
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #1B5E56
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..106d7de
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,40 @@
+
+
+ VulneraDoc
+
+ Wunddokumentationen
+ Patient oder Geburtsdatum suchen
+ Noch keine Wunddokumentationen vorhanden.
+ Neue Dokumentation
+ Bearbeiten
+ Fortsetzen
+ Löschen
+ Dokumentation löschen?
+ Diese Wunddokumentation inklusive aller Fotos und Unterschriften wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
+
+ Patienten
+ Name oder Geburtsdatum suchen
+ Noch keine Patienten vorhanden.
+ Neuer Patient
+ Löschen nicht möglich
+ Für diesen Patienten existieren noch Wunddokumentationen. Diese müssen zuerst gelöscht werden, bevor der Patient gelöscht werden kann.
+
+ Neuer Patient
+ Patient bearbeiten
+ Vorname
+ Nachname
+ Geburtsdatum
+ Adresse
+ Telefonnummer 1
+ Telefonnummer 2 (optional)
+ Telefonnummer 3 (optional)
+ Speichern
+
+ %1$d / %2$d
+ Zurück
+ Weiter
+ Freitext
+
+ Abbrechen
+ Bestätigen
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..013ce11
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..2d9b364
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..fd6169d
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..497128a
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,10 @@
+// Root-Build-Datei. Die eigentliche Modul-Konfiguration liegt in app/build.gradle.kts.
+// Hier werden nur die Plugin-Versionen zentral deklariert (apply false = im Root nicht
+// direkt angewendet, sondern nur als Version für die Module verfügbar gemacht).
+plugins {
+ id("com.android.application") version "8.9.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.20" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" apply false
+ id("com.google.devtools.ksp") version "2.1.20-1.0.31" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..cbfee75
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,3 @@
+#This file is generated by updateDaemonJvm
+toolchainVendor=microsoft
+toolchainVersion=17
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..9715a88
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..d95bf61
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..640d686
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..52fe9f4
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,21 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "VulneraDoc"
+include(":app")