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 @@ + + + +