CORE-001 Android-Projekt anlegen Feature #1

Merged
niklas.ortmann merged 1 commits from main into dev 2026-08-20 08:47:34 +02:00
88 changed files with 3733 additions and 0 deletions
Showing only changes of commit 251a23dbe1 - Show all commits
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
@@ -0,0 +1,2 @@
#Thu Aug 20 08:33:55 CEST 2026
gradle.version=8.11.1
Binary file not shown.
Binary file not shown.
View File
+120
View File
@@ -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")
}
+3
View File
@@ -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").
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. -->
<application
android:name=".WunddokuApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.Wunddoku"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Wunddoku">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Einziger Weg, wie Dateien aus dem internen App-Speicher (filesDir) nach außen
(PDF-Reader, Sharesheet) gelangen: gezielt über content://-URIs, nie über
file://-URIs oder öffentlichen Speicher. Siehe Abschnitt 13/10 der Architektur. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,42 @@
package de.medapp.wunddoku
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import de.medapp.wunddoku.ui.navigation.WunddokuNavGraph
import de.medapp.wunddoku.ui.theme.WunddokuTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// FLAG_SECURE (Freigabe-Entscheidung aus Phase 1, Architektur-Dokument Abschnitt 13):
// verhindert, dass Wunddaten in Screenshots oder in der App-Switcher-Vorschau
// sichtbar werden, da die App sensible Patientendaten verarbeitet.
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
setContent {
WunddokuApp()
}
}
}
@Composable
private fun WunddokuApp() {
WunddokuTheme {
Surface(modifier = Modifier.fillMaxSize()) {
WunddokuNavGraph()
}
}
}
@@ -0,0 +1,15 @@
package de.medapp.wunddoku
import android.app.Application
import de.medapp.wunddoku.di.AppContainer
class WunddokuApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
@@ -0,0 +1,28 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import de.medapp.wunddoku.data.entity.BlockStateEntity
/**
* Bewusst schlanke DAO: Nur Datenbankzugriffe, keine Koordination mehrerer Tabellen (das
* übernimmt DocumentationRepository). Kein `update` für ganze Entities nötig, da nur
* `freeText`/`updatedAt` sich nach dem Erstellen je ändern (Autosave) – ein gezieltes
* Teil-Update ist hier klarer als ein vollständiges Entity-Update.
*/
@Dao
interface BlockStateDao {
@Insert
suspend fun insertAll(blockStates: List<BlockStateEntity>)
@Query("SELECT * FROM block_states WHERE id = :id")
suspend fun getById(id: String): BlockStateEntity?
@Query("SELECT * FROM block_states WHERE documentationId = :documentationId ORDER BY blockOrder")
suspend fun getForDocumentation(documentationId: String): List<BlockStateEntity>
@Query("UPDATE block_states SET freeText = :freeText, updatedAt = :updatedAt WHERE id = :id")
suspend fun updateFreeText(id: String, freeText: String, updatedAt: Long)
}
@@ -0,0 +1,19 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
@Dao
interface CheckItemStateDao {
@Insert
suspend fun insertAll(checkItemStates: List<CheckItemStateEntity>)
@Query("SELECT * FROM check_item_states WHERE blockStateId = :blockStateId ORDER BY itemOrder")
suspend fun getForBlockState(blockStateId: String): List<CheckItemStateEntity>
@Query("UPDATE check_item_states SET checked = :checked WHERE id = :id")
suspend fun setChecked(id: String, checked: Boolean)
}
@@ -0,0 +1,73 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient
import kotlinx.coroutines.flow.Flow
@Dao
interface DocumentationDao {
@Insert
suspend fun insert(documentation: DocumentationEntity)
@Update
suspend fun update(documentation: DocumentationEntity)
@Delete
suspend fun delete(documentation: DocumentationEntity)
@Query("SELECT * FROM documentations WHERE id = :id")
suspend fun getById(id: String): DocumentationEntity?
/**
* Für die Startscreen-Liste (Aufgabenstellung Abschnitt 5): zeigt alle Dokumentationen mit
* Patientenbezug, ohne die vollständigen Blöcke zu laden – bewusst schlank für Performance
* bei größeren Datenmengen.
*/
@Transaction
@Query("SELECT * FROM documentations ORDER BY createdAt DESC")
fun observeAllWithPatient(): Flow<List<DocumentationWithPatient>>
@Transaction
@Query(
"""
SELECT d.* FROM documentations d
JOIN patients p ON p.id = d.patientId
WHERE p.lastName LIKE '%' || :query || '%'
OR p.firstName LIKE '%' || :query || '%'
OR p.birthDate LIKE '%' || :query || '%'
ORDER BY d.createdAt DESC
"""
)
fun search(query: String): Flow<List<DocumentationWithPatient>>
@Transaction
@Query("SELECT * FROM documentations WHERE id = :id")
suspend fun getFullDocumentation(id: String): DocumentationWithDetails?
/**
* Für "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9): lädt die zuletzt erstellte
* Dokumentation eines Patienten inklusive aller Blöcke/Checkboxen (Fotos/Unterschriften
* werden je nach Kopiersemantik separat behandelt, s. DocumentationRepository).
*/
@Transaction
@Query(
"""
SELECT * FROM documentations
WHERE patientId = :patientId
ORDER BY createdAt DESC
LIMIT 1
"""
)
suspend fun getLatestForPatient(patientId: String): DocumentationWithDetails?
@Query("SELECT COUNT(*) FROM documentations WHERE patientId = :patientId")
suspend fun countForPatient(patientId: String): Int
}
@@ -0,0 +1,80 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Embedded
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import de.medapp.wunddoku.data.entity.PatientEntity
import kotlinx.coroutines.flow.Flow
/**
* [PatientEntity] zusammen mit dem Erstellungsdatum der zuletzt angelegten Dokumentation dieses
* Patienten (oder `null`, falls noch keine existiert). Wird über eine Korrelations-Subquery in
* einem einzigen Datenbankzugriff berechnet, statt pro Patient eine eigene Folgeabfrage
* auszuführen (N+1-Problem) – wichtig für die geforderte Performance bei größeren Datenmengen
* (Aufgabenstellung Abschnitt 6).
*/
data class PatientWithLastDocumentation(
@Embedded val patient: PatientEntity,
val lastDocumentationAt: Long?,
)
@Dao
interface PatientDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(patient: PatientEntity)
@Update
suspend fun update(patient: PatientEntity)
@Delete
suspend fun delete(patient: PatientEntity)
@Query("SELECT * FROM patients WHERE id = :id")
suspend fun getById(id: String): PatientEntity?
@Query(
"""
SELECT p.*, (SELECT MAX(d.createdAt) FROM documentations d WHERE d.patientId = p.id) AS lastDocumentationAt
FROM patients p
ORDER BY p.lastName, p.firstName
"""
)
fun observeAllWithLastDocumentation(): Flow<List<PatientWithLastDocumentation>>
@Query(
"""
SELECT p.*, (SELECT MAX(d.createdAt) FROM documentations d WHERE d.patientId = p.id) AS lastDocumentationAt
FROM patients p
WHERE p.lastName LIKE '%' || :query || '%'
OR p.firstName LIKE '%' || :query || '%'
OR p.birthDate LIKE '%' || :query || '%'
ORDER BY p.lastName, p.firstName
"""
)
fun searchWithLastDocumentation(query: String): Flow<List<PatientWithLastDocumentation>>
/**
* Wird beim Anlegen einer neuen Dokumentation verwendet, um zu prüfen, ob ein Patient mit
* denselben Stammdaten bereits existiert (Aufgabenstellung Abschnitt 7 Schritt 1: "möglichst
* ein vorhandener Patient ausgewählt werden können, statt einen Duplikatdatensatz
* anzulegen"). Exakter Abgleich auf Name + Geburtsdatum als einfache, nachvollziehbare
* Heuristik für V1.
*/
@Query(
"""
SELECT * FROM patients
WHERE firstName = :firstName AND lastName = :lastName AND birthDate = :birthDate
LIMIT 1
"""
)
suspend fun findExisting(firstName: String, lastName: String, birthDate: String): PatientEntity?
@Query("SELECT EXISTS(SELECT 1 FROM documentations WHERE patientId = :patientId)")
suspend fun hasDocumentations(patientId: String): Boolean
}
@@ -0,0 +1,26 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import de.medapp.wunddoku.data.entity.PhotoEntity
@Dao
interface PhotoDao {
@Insert
suspend fun insert(photo: PhotoEntity)
@Delete
suspend fun delete(photo: PhotoEntity)
@Query("SELECT * FROM photos WHERE id = :id")
suspend fun getById(id: String): PhotoEntity?
@Query("SELECT * FROM photos WHERE documentationId = :documentationId ORDER BY orderIndex")
suspend fun getForDocumentation(documentationId: String): List<PhotoEntity>
@Query("SELECT COALESCE(MAX(orderIndex), -1) FROM photos WHERE documentationId = :documentationId")
suspend fun getMaxOrderIndex(documentationId: String): Int
}
@@ -0,0 +1,23 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import de.medapp.wunddoku.data.entity.SignatureEntity
@Dao
interface SignatureDao {
@Insert
suspend fun insert(signature: SignatureEntity)
@Delete
suspend fun delete(signature: SignatureEntity)
@Query("SELECT * FROM signatures WHERE id = :id")
suspend fun getById(id: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId")
suspend fun getForDocumentation(documentationId: String): List<SignatureEntity>
}
@@ -0,0 +1,65 @@
package de.medapp.wunddoku.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import de.medapp.wunddoku.data.dao.BlockStateDao
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
/**
* Zentrale Room-Datenbank. `exportSchema = true`, damit Room die Schema-Historie als JSON in
* app/schemas/ ablegt (im Gradle-Skript über `room { schemaDirectory(...) }` zu konfigurieren,
* s. Kommentar unten) – das ist die Grundlage für spätere, nachvollziehbare Migrationen.
*/
@Database(
entities = [
PatientEntity::class,
DocumentationEntity::class,
BlockStateEntity::class,
CheckItemStateEntity::class,
PhotoEntity::class,
SignatureEntity::class,
],
version = 1,
exportSchema = true,
)
@TypeConverters(DocumentationStatusConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun patientDao(): PatientDao
abstract fun documentationDao(): DocumentationDao
abstract fun blockStateDao(): BlockStateDao
abstract fun checkItemStateDao(): CheckItemStateDao
abstract fun photoDao(): PhotoDao
abstract fun signatureDao(): SignatureDao
companion object {
private const val DATABASE_NAME = "wunddoku.db"
@Volatile
private var instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
DATABASE_NAME,
).build().also { instance = it }
}
}
}
}
@@ -0,0 +1,45 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room-Entity für den Zustand eines einzelnen Dokumentationsblocks innerhalb einer konkreten
* Wunddokumentation. Generische Struktur statt 20 fester Spalten/Tabellen (Architektur-Dokument
* Abschnitt 2.3a) – neue Blöcke im [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]
* erfordern keine Schema-Migration.
*
* [blockDefinitionId] verweist auf die ID im statischen Katalog, ist aber bewusst KEIN
* Foreign Key: Der Katalog ist keine Datenbanktabelle, sondern Kotlin-Code.
*
* [titleSnapshot]/[blockOrder] speichern den zum Erstellungszeitpunkt der Dokumentation
* gültigen Titel bzw. die gültige Reihenfolge (Architektur-Dokument Abschnitt 2.4) – das
* stellt sicher, dass eine später geöffnete, historische Dokumentation unverändert korrekt
* dargestellt wird, selbst wenn sich der Katalog danach ändert.
*/
@Entity(
tableName = "block_states",
foreignKeys = [
ForeignKey(
entity = DocumentationEntity::class,
parentColumns = ["id"],
childColumns = ["documentationId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["documentationId"]),
Index(value = ["documentationId", "blockDefinitionId"], unique = true),
],
)
data class BlockStateEntity(
@PrimaryKey val id: String,
val documentationId: String,
val blockDefinitionId: String,
val titleSnapshot: String,
val blockOrder: Int,
val freeText: String,
val updatedAt: Long,
)
@@ -0,0 +1,36 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room-Entity für den Zustand einer einzelnen Checkbox innerhalb eines [BlockStateEntity].
*
* [labelSnapshot]/[itemOrder]: siehe Erläuterung bei [BlockStateEntity.titleSnapshot] – gleiche
* Begründung, auf Checkbox-Ebene.
*/
@Entity(
tableName = "check_item_states",
foreignKeys = [
ForeignKey(
entity = BlockStateEntity::class,
parentColumns = ["id"],
childColumns = ["blockStateId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["blockStateId"]),
Index(value = ["blockStateId", "checkItemDefinitionId"], unique = true),
],
)
data class CheckItemStateEntity(
@PrimaryKey val id: String,
val blockStateId: String,
val checkItemDefinitionId: String,
val labelSnapshot: String,
val itemOrder: Int,
val checked: Boolean,
)
@@ -0,0 +1,52 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import de.medapp.wunddoku.domain.model.DocumentationStatus
/**
* Room-Entity für eine Wunddokumentation.
*
* Foreign-Key-Verhalten (Architektur-Dokument Abschnitt 2.3f):
* - patientId -> Patient.id: RESTRICT. Ein Patient mit vorhandenen Dokumentationen kann in
* der Datenbank NICHT gelöscht werden. Das setzt die Anforderung "kein versehentliches
* Cascade-Delete von Dokumentationen" technisch durch – die UI muss das vorher explizit
* abfangen (siehe PatientRepository.hasDocumentations).
* - continuedFromDocumentationId -> Documentation.id: SET_NULL. Löschen einer alten
* Dokumentation darf eine neuere, davon "fortgesetzte" Dokumentation weder blockieren noch
* mitreißen; der Verweis wird beim Löschen des Vorgängers einfach entfernt.
*/
@Entity(
tableName = "documentations",
foreignKeys = [
ForeignKey(
entity = PatientEntity::class,
parentColumns = ["id"],
childColumns = ["patientId"],
onDelete = ForeignKey.RESTRICT,
),
ForeignKey(
entity = DocumentationEntity::class,
parentColumns = ["id"],
childColumns = ["continuedFromDocumentationId"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [
Index(value = ["patientId", "createdAt"]),
Index(value = ["status"]),
Index(value = ["continuedFromDocumentationId"]),
],
)
data class DocumentationEntity(
@PrimaryKey val id: String,
val patientId: String,
val status: DocumentationStatus,
val createdAt: Long,
val updatedAt: Long,
val continuedFromDocumentationId: String?,
val pdfLastGeneratedAt: Long?,
val catalogVersion: Int,
)
@@ -0,0 +1,35 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room-Entity für einen Patienten. [id] ist eine UUID (kein Auto-Increment), damit Datensätze
* geräteübergreifend eindeutig bleiben – wichtig für die spätere optionale WLAN-Synchronisation
* (Architektur-Dokument Abschnitt 2.1).
*
* [birthDate] wird als ISO-8601-String (z. B. "1980-03-14") gespeichert statt über einen
* TypeConverter auf LocalDate: Der String ist bereits das native SQLite-Format, direkt
* sortierbar und ohne Konverter lesbar. Der Mapper (PatientMapper) übernimmt die Umwandlung
* zu/von `java.time.LocalDate` beim Übergang zur Domain-Schicht.
*/
@Entity(
tableName = "patients",
indices = [
Index(value = ["lastName", "firstName"]),
Index(value = ["birthDate"]),
],
)
data class PatientEntity(
@PrimaryKey val id: String,
val firstName: String,
val lastName: String,
val birthDate: String,
val address: String,
val phone1: String,
val phone2: String?,
val phone3: String?,
val createdAt: Long,
val updatedAt: Long,
)
@@ -0,0 +1,35 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room-Entity für ein an eine Dokumentation angehängtes Foto.
*
* [fileName] ist ein relativer Pfad innerhalb von context.filesDir/photos/ – kein BLOB, keine
* absolute Pfadangabe (robust gegenüber möglichen Änderungen des App-Datenverzeichnisses durch
* das System). Siehe Aufgabenstellung Abschnitt 10.
*/
@Entity(
tableName = "photos",
foreignKeys = [
ForeignKey(
entity = DocumentationEntity::class,
parentColumns = ["id"],
childColumns = ["documentationId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["documentationId", "orderIndex"]),
],
)
data class PhotoEntity(
@PrimaryKey val id: String,
val documentationId: String,
val fileName: String,
val orderIndex: Int,
val createdAt: Long,
)
@@ -0,0 +1,34 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an
* DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen
* (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter
* context.filesDir/signatures/ – kein BLOB in SQLite.
*/
@Entity(
tableName = "signatures",
foreignKeys = [
ForeignKey(
entity = DocumentationEntity::class,
parentColumns = ["id"],
childColumns = ["documentationId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["documentationId"]),
],
)
data class SignatureEntity(
@PrimaryKey val id: String,
val documentationId: String,
val signerName: String?,
val fileName: String,
val createdAt: Long,
)
@@ -0,0 +1,28 @@
package de.medapp.wunddoku.data.entity.converter
import androidx.room.TypeConverter
import de.medapp.wunddoku.domain.model.DocumentationStatus
/**
* Speichert [DocumentationStatus] als lesbaren Text (z. B. "COMPLETED"), NICHT als
* Ordinal-Index (0, 1, ...).
*
* Kotlin-Hinweis: `@TypeConverter` markiert Funktionen, die Room bei Bedarf automatisch
* aufruft, um einen Typ, den SQLite nicht nativ kennt (hier: das Enum), in einen
* speicherbaren Typ (hier: String) umzuwandeln und zurück. Ich verwende bewusst einen
* expliziten Konverter statt mich auf automatisches Enum-Handling neuerer Room-Versionen zu
* verlassen – das macht das Verhalten unabhängig von der genauen Room-Version nachvollziehbar.
*
* Der Ordinal-Wert eines Enums hängt von der Deklarationsreihenfolge im Code ab. Würde später
* z. B. CANCELLED vor COMPLETED eingefügt, würden sich bei Ordinal-Speicherung alle
* bestehenden Datensätze plötzlich falsch zuordnen. Der Name ist stabil gegenüber
* Reihenfolgeänderungen und zusätzlich beim Debuggen in einem SQLite-Browser direkt lesbar.
*/
class DocumentationStatusConverter {
@TypeConverter
fun fromStatus(status: DocumentationStatus): String = status.name
@TypeConverter
fun toStatus(value: String): DocumentationStatus = DocumentationStatus.valueOf(value)
}
@@ -0,0 +1,71 @@
package de.medapp.wunddoku.data.entity.relations
import androidx.room.Embedded
import androidx.room.Relation
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
/**
* Verschachtelte Room-Relation: ein Block zusammen mit seinen Checkbox-Zuständen.
*
* Kotlin/Room-Hinweis: `@Embedded` bedeutet, dass die Spalten von [BlockStateEntity] direkt in
* dieses Objekt "eingebettet" werden (kein separates verschachteltes Objekt in der
* SQL-Ergebnisstruktur). `@Relation` lässt Room automatisch eine zweite Query ausführen, um
* alle [CheckItemStateEntity]-Zeilen zu laden, deren `blockStateId`-Spalte zur `id`-Spalte
* dieses BlockState passt.
*/
data class BlockStateWithItems(
@Embedded val blockState: BlockStateEntity,
@Relation(
parentColumn = "id",
entityColumn = "blockStateId",
)
val checkItems: List<CheckItemStateEntity>,
)
/**
* Vollständige Dokumentation inkl. aller Blöcke (mit ihren Checkboxen), Fotos und
* Unterschriften. Wird ausschließlich über `@Transaction`-Queries in [de.medapp.wunddoku.data.dao.DocumentationDao]
* geladen, damit das Ergebnis konsistent ist (Aufgabenstellung Abschnitt 16, letzter Punkt).
*
* Kotlin/Room-Hinweis: Eine mehrstufig verschachtelte `@Relation` (hier: Blöcke, die selbst
* wieder ihre Checkboxen per `@Relation` laden) wird von Room unterstützt, solange bei
* `entity = ...` explizit die zugrunde liegende Room-Entity angegeben wird – auch wenn der
* Ziel-Typ ([BlockStateWithItems]) selbst kein Entity, sondern eine weitere POJO/Relation ist.
*/
data class DocumentationWithDetails(
@Embedded val documentation: DocumentationEntity,
@Relation(
entity = BlockStateEntity::class,
parentColumn = "id",
entityColumn = "documentationId",
)
val blocks: List<BlockStateWithItems>,
@Relation(
parentColumn = "id",
entityColumn = "documentationId",
)
val photos: List<PhotoEntity>,
@Relation(
parentColumn = "id",
entityColumn = "documentationId",
)
val signatures: List<SignatureEntity>,
)
/**
* Schlanke Relation für Listenansichten: Dokumentation + zugehöriger Patient, ohne Blöcke/
* Fotos/Unterschriften zu laden. Wird für die Wunddokumentationsliste (Startscreen) verwendet.
*/
data class DocumentationWithPatient(
@Embedded val documentation: DocumentationEntity,
@Relation(
parentColumn = "patientId",
entityColumn = "id",
)
val patient: PatientEntity,
)
@@ -0,0 +1,80 @@
package de.medapp.wunddoku.data.mapper
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.relations.BlockStateWithItems
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.data.entity.relations.DocumentationWithPatient
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationSummary
import java.time.Instant
/**
* Wandelt zwischen den Room-Relations und den Domain-Modellen [Documentation]/
* [DocumentationSummary] um. Titel/Label kommen ausschließlich aus den Snapshot-Feldern
* ([BlockStateEntity.titleSnapshot], [CheckItemStateEntity.labelSnapshot]) – hier findet KEINE
* Auflösung über [de.medapp.wunddoku.domain.catalog.DocumentationCatalog] statt (Architektur-
* Dokument Abschnitt 2.4).
*/
fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
id = documentation.id,
patientId = documentation.patientId,
status = documentation.status,
createdAt = Instant.ofEpochMilli(documentation.createdAt),
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
continuedFromDocumentationId = documentation.continuedFromDocumentationId,
catalogVersion = documentation.catalogVersion,
blocks = blocks
.sortedBy { it.blockState.blockOrder }
.map { it.toDomain() },
photos = photos
.sortedBy { it.orderIndex }
.map {
de.medapp.wunddoku.domain.model.Photo(
id = it.id,
documentationId = it.documentationId,
fileName = it.fileName,
orderIndex = it.orderIndex,
createdAt = Instant.ofEpochMilli(it.createdAt),
)
},
signatures = signatures.map {
de.medapp.wunddoku.domain.model.Signature(
id = it.id,
documentationId = it.documentationId,
signerName = it.signerName,
fileName = it.fileName,
createdAt = Instant.ofEpochMilli(it.createdAt),
)
},
)
fun BlockStateWithItems.toDomain(): DocumentationBlock = DocumentationBlock(
blockStateId = blockState.id,
definitionId = blockState.blockDefinitionId,
title = blockState.titleSnapshot,
order = blockState.blockOrder,
freeText = blockState.freeText,
items = checkItems
.sortedBy { it.itemOrder }
.map { it.toDomain() },
)
fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
checkItemStateId = id,
definitionId = checkItemDefinitionId,
label = labelSnapshot,
order = itemOrder,
checked = checked,
)
fun DocumentationWithPatient.toSummary(): DocumentationSummary = DocumentationSummary(
id = documentation.id,
patientId = documentation.patientId,
patientFullName = "${patient.firstName} ${patient.lastName}",
status = documentation.status,
createdAt = Instant.ofEpochMilli(documentation.createdAt),
updatedAt = Instant.ofEpochMilli(documentation.updatedAt),
)
@@ -0,0 +1,41 @@
package de.medapp.wunddoku.data.mapper
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.domain.model.Patient
import java.time.Instant
import java.time.LocalDate
/**
* Wandelt zwischen der Room-Entity [PatientEntity] und dem Domain-Modell [Patient] um.
*
* Kotlin-Hinweis: Dies sind sogenannte Extension Functions (`fun PatientEntity.toDomain()`)
* – sie erweitern eine bestehende Klasse um eine Methode, ohne die Klasse selbst zu verändern
* oder von ihr zu erben. Der Aufruf sieht dadurch wie eine normale Methode aus:
* `patientEntity.toDomain()`. Praktisch, um Mapping-Logik nahe an den beteiligten Typen zu
* halten, ohne eine eigene Mapper-Klasse mit Konstruktor-Injection zu benötigen.
*/
fun PatientEntity.toDomain(): Patient = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = LocalDate.parse(birthDate),
address = address,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
createdAt = Instant.ofEpochMilli(createdAt),
updatedAt = Instant.ofEpochMilli(updatedAt),
)
fun Patient.toEntity(): PatientEntity = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate.toString(), // LocalDate.toString() liefert bereits ISO-8601 (yyyy-MM-dd)
address = address,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
createdAt = createdAt.toEpochMilli(),
updatedAt = updatedAt.toEpochMilli(),
)
@@ -0,0 +1,291 @@
package de.medapp.wunddoku.data.repository
import android.graphics.Bitmap
import android.net.Uri
import androidx.room.withTransaction
import de.medapp.wunddoku.data.dao.BlockStateDao
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.mapper.toDomain
import de.medapp.wunddoku.data.mapper.toSummary
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.DocumentationSummary
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureFileStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.Instant
import java.util.UUID
/**
* Koordiniert alle Lese-/Schreiboperationen rund um eine Wunddokumentation über mehrere DAOs
* hinweg. ViewModels sehen niemals mehr als dieses eine Repository für Dokumentations-bezogene
* Daten – Mehrfach-DAO-Koordination und Room-Transaktionen (`db.withTransaction`) sind
* ausschließlich hier gebündelt (Architektur-Dokument Abschnitt 5).
*/
class DocumentationRepository(
private val documentationDao: DocumentationDao,
private val blockStateDao: BlockStateDao,
private val checkItemStateDao: CheckItemStateDao,
private val photoDao: PhotoDao,
private val signatureDao: SignatureDao,
private val photoStorage: PhotoStorage,
private val signatureFileStorage: SignatureFileStorage,
private val db: AppDatabase,
) {
fun observeAll(): Flow<List<DocumentationSummary>> =
documentationDao.observeAllWithPatient().map { list -> list.map { it.toSummary() } }
fun search(query: String): Flow<List<DocumentationSummary>> =
documentationDao.search(query).map { list -> list.map { it.toSummary() } }
suspend fun getFullDocumentation(id: String): Documentation? =
documentationDao.getFullDocumentation(id)?.toDomain()
/**
* Legt eine neue, leere Wunddokumentation für [patientId] an: eine [DocumentationEntity]
* plus je einen [BlockStateEntity] pro Katalog-Block (mit Label-/Titel-Snapshot aus dem
* *aktuellen* Katalogstand, Architektur-Dokument Abschnitt 2.4) und je einen
* [CheckItemStateEntity] pro Katalog-Checkbox. Läuft in einer einzigen Room-Transaktion,
* damit niemals eine halb angelegte Dokumentation (z. B. Documentation-Zeile ohne
* zugehörige Blöcke) entstehen kann.
*/
suspend fun createEmpty(patientId: String): Documentation = db.withTransaction {
val now = Instant.now().toEpochMilli()
val documentationId = UUID.randomUUID().toString()
documentationDao.insert(
DocumentationEntity(
id = documentationId,
patientId = patientId,
status = DocumentationStatus.IN_PROGRESS,
createdAt = now,
updatedAt = now,
continuedFromDocumentationId = null,
pdfLastGeneratedAt = null,
catalogVersion = DocumentationCatalog.CATALOG_VERSION,
)
)
insertBlocksFromCatalog(documentationId, now)
documentationDao.getFullDocumentation(documentationId)!!.toDomain()
}
/**
* "Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 9, präzisiert in Architektur-
* Dokument Abschnitt 6):
* - Patient: gleiche patientId, kein neuer Patientendatensatz
* - BlockState/CheckItemState: vollständig von der Quelle kopiert, inkl. Snapshots und
* Reihenfolge der QUELLE (nicht neu aus dem aktuellen Katalog gezogen) – die fortgesetzte
* Dokumentation beginnt exakt auf dem inhaltlichen Stand des Vorgängers.
* - createdAt/updatedAt: neu
* - status: IN_PROGRESS
* - continuedFromDocumentationId: ID der Quelle
* - Unterschriften: werden NICHT kopiert (eine Unterschrift bestätigt nur die konkrete
* Dokumentation, unter der sie geleistet wurde)
* - Fotos: über [copyPhotos] steuerbar, Standard `false` – fachlich noch nicht final
* entschieden (siehe Architektur-Dokument Abschnitt 6)
*/
suspend fun continueDocumentation(
sourceDocumentationId: String,
copyPhotos: Boolean = false,
): Documentation = db.withTransaction {
val source = documentationDao.getFullDocumentation(sourceDocumentationId)
?: error("Quell-Dokumentation $sourceDocumentationId nicht gefunden")
val now = Instant.now().toEpochMilli()
val newDocumentationId = UUID.randomUUID().toString()
documentationDao.insert(
DocumentationEntity(
id = newDocumentationId,
patientId = source.documentation.patientId,
status = DocumentationStatus.IN_PROGRESS,
createdAt = now,
updatedAt = now,
continuedFromDocumentationId = sourceDocumentationId,
pdfLastGeneratedAt = null,
catalogVersion = source.documentation.catalogVersion,
)
)
// Blöcke + Checkboxen 1:1 von der Quelle übernehmen (inkl. Snapshots/Reihenfolge)
for (sourceBlock in source.blocks) {
val newBlockStateId = UUID.randomUUID().toString()
blockStateDao.insertAll(
listOf(
BlockStateEntity(
id = newBlockStateId,
documentationId = newDocumentationId,
blockDefinitionId = sourceBlock.blockState.blockDefinitionId,
titleSnapshot = sourceBlock.blockState.titleSnapshot,
blockOrder = sourceBlock.blockState.blockOrder,
freeText = sourceBlock.blockState.freeText,
updatedAt = now,
)
)
)
checkItemStateDao.insertAll(
sourceBlock.checkItems.map { item ->
CheckItemStateEntity(
id = UUID.randomUUID().toString(),
blockStateId = newBlockStateId,
checkItemDefinitionId = item.checkItemDefinitionId,
labelSnapshot = item.labelSnapshot,
itemOrder = item.itemOrder,
checked = item.checked,
)
}
)
}
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
if (copyPhotos) {
for (sourcePhoto in source.photos) {
val copiedFileName = photoStorage.duplicate(sourcePhoto.fileName)
photoDao.insert(
PhotoEntity(
id = UUID.randomUUID().toString(),
documentationId = newDocumentationId,
fileName = copiedFileName,
orderIndex = sourcePhoto.orderIndex,
createdAt = now,
)
)
}
}
documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain()
}
/**
* Komfort-Methode für die Patientenliste (Aufgabenstellung Abschnitt 6, Aktion
* "Behandlung/Dokumentation fortsetzen"): ermittelt die zuletzt erstellte Dokumentation
* eines Patienten und ruft darauf [continueDocumentation] auf. Liefert `null`, wenn der
* Patient noch keine Dokumentation besitzt (UI muss diesen Fall abfangen, s. Aufgabenstellung
* Abschnitt 20).
*/
suspend fun continueLatestForPatient(patientId: String): Documentation? {
val latestId = documentationDao.getLatestForPatient(patientId)?.documentation?.id ?: return null
return continueDocumentation(latestId)
}
suspend fun updateFreeText(blockStateId: String, text: String) {
blockStateDao.updateFreeText(blockStateId, text, Instant.now().toEpochMilli())
}
suspend fun setChecked(checkItemStateId: String, checked: Boolean) {
checkItemStateDao.setChecked(checkItemStateId, checked)
}
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
val fileName = photoStorage.copyFrom(sourceUri)
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now()
val entity = PhotoEntity(
id = UUID.randomUUID().toString(),
documentationId = documentationId,
fileName = fileName,
orderIndex = nextOrder,
createdAt = now.toEpochMilli(),
)
photoDao.insert(entity)
return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now)
}
/**
* Löscht ein Foto. Bewusst NICHT als atomare Operation bezeichnet: Eine Room-Transaktion
* kann keine Dateisystemoperation einschließen. Reihenfolge: DB-Zeile zuerst, dann Datei –
* schlägt die Dateilöschung fehl, bleibt nur eine verwaiste Datei zurück (unkritisch),
* statt eines DB-Eintrags, der auf eine nicht mehr existente Datei verweist (würde beim
* Anzeigen/PDF-Export zu einem sichtbaren Fehler führen). Siehe Architektur-Dokument
* Abschnitt 5.1.
*/
suspend fun removePhoto(photoId: String) {
val photo = photoDao.getById(photoId) ?: return
photoDao.delete(photo)
runCatching { photoStorage.delete(photo.fileName) }
// Fehler beim Löschen der physischen Datei wird bewusst verschluckt: Die DB ist bereits
// konsistent, eine verwaiste Datei ist der unkritischere Fehlerfall. Kein Logging von
// Dateinamen mit Patientenbezug, s. Datenschutz-Abschnitt der Architektur.
}
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature {
val fileName = signatureFileStorage.save(bitmap)
val now = Instant.now()
val entity = SignatureEntity(
id = UUID.randomUUID().toString(),
documentationId = documentationId,
signerName = signerName,
fileName = fileName,
createdAt = now.toEpochMilli(),
)
signatureDao.insert(entity)
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now)
}
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */
suspend fun removeSignature(signatureId: String) {
val signature = signatureDao.getById(signatureId) ?: return
signatureDao.delete(signature)
runCatching { signatureFileStorage.delete(signature.fileName) }
}
suspend fun delete(documentationId: String) {
val entity = documentationDao.getById(documentationId) ?: return
// Zugehörige Fotos/Signaturen zuerst physisch löschen (best effort), dann die
// Dokumentation selbst – CASCADE übernimmt das Entfernen der DB-Zeilen für
// BlockState/CheckItemState/Photo/Signature.
val photos = photoDao.getForDocumentation(documentationId)
val signatures = signatureDao.getForDocumentation(documentationId)
documentationDao.delete(entity)
photos.forEach { runCatching { photoStorage.delete(it.fileName) } }
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } }
}
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) {
val blockStateId = UUID.randomUUID().toString()
blockStateDao.insertAll(
listOf(
BlockStateEntity(
id = blockStateId,
documentationId = documentationId,
blockDefinitionId = blockDef.id,
titleSnapshot = blockDef.title,
blockOrder = blockDef.order,
freeText = "",
updatedAt = now,
)
)
)
checkItemStateDao.insertAll(
blockDef.items.map { itemDef ->
CheckItemStateEntity(
id = UUID.randomUUID().toString(),
blockStateId = blockStateId,
checkItemDefinitionId = itemDef.id,
labelSnapshot = itemDef.label,
itemOrder = itemDef.order,
checked = false,
)
}
)
}
}
}
@@ -0,0 +1,73 @@
package de.medapp.wunddoku.data.repository
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PatientWithLastDocumentation
import de.medapp.wunddoku.data.mapper.toDomain
import de.medapp.wunddoku.data.mapper.toEntity
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.domain.model.PatientListItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
class PatientRepository(
private val patientDao: PatientDao,
) {
fun observeAll(): Flow<List<PatientListItem>> =
patientDao.observeAllWithLastDocumentation().map { list -> list.map { it.toListItem() } }
fun search(query: String): Flow<List<PatientListItem>> =
patientDao.searchWithLastDocumentation(query).map { list -> list.map { it.toListItem() } }
suspend fun getById(id: String): Patient? = patientDao.getById(id)?.toDomain()
suspend fun findExisting(firstName: String, lastName: String, birthDate: LocalDate): Patient? =
patientDao.findExisting(firstName, lastName, birthDate.toString())?.toDomain()
fun newId(): String = UUID.randomUUID().toString()
/**
* Legt einen neuen Patienten an oder aktualisiert einen bestehenden, je nachdem, ob unter
* [patient.id] bereits ein Datensatz existiert. `createdAt`/`updatedAt` werden zentral hier
* gepflegt, damit ViewModels das nicht selbst verwalten müssen.
*/
suspend fun upsert(patient: Patient): Patient {
val existing = patientDao.getById(patient.id)
val now = Instant.now()
val toPersist = if (existing == null) {
patient.copy(createdAt = now, updatedAt = now)
} else {
patient.copy(createdAt = Instant.ofEpochMilli(existing.createdAt), updatedAt = now)
}
if (existing == null) {
patientDao.insert(toPersist.toEntity())
} else {
patientDao.update(toPersist.toEntity())
}
return toPersist
}
suspend fun hasDocumentations(patientId: String): Boolean =
patientDao.hasDocumentations(patientId)
/**
* Löscht den Patienten. Der Aufrufer (ViewModel) sollte vorher [hasDocumentations] prüfen,
* um dem Nutzer eine verständliche Fehlermeldung statt einer rohen Datenbank-Exception zu
* zeigen (Aufgabenstellung Abschnitt 20). Die eigentliche Integrität wird zusätzlich durch
* `ON DELETE RESTRICT` in der Datenbank selbst garantiert (Architektur-Dokument Abschnitt 2.3f).
*/
suspend fun delete(patientId: String) {
val entity = patientDao.getById(patientId) ?: return
patientDao.delete(entity)
}
private fun PatientWithLastDocumentation.toListItem(): PatientListItem = PatientListItem(
id = patient.id,
fullName = "${patient.firstName} ${patient.lastName}",
birthDate = LocalDate.parse(patient.birthDate),
address = patient.address,
lastDocumentationAt = lastDocumentationAt?.let { Instant.ofEpochMilli(it) },
)
}
@@ -0,0 +1,47 @@
package de.medapp.wunddoku.di
import android.content.Context
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.media.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.InternalSignatureFileStorage
import de.medapp.wunddoku.signature.SignatureFileStorage
/**
* Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories,
* eine Handvoll ViewModels, wenige Storage-/Exporter-Klassen) reicht eine von Hand geschriebene
* Objekt-Fabrik. Das spart eine zusätzliche Abhängigkeit samt Annotationsverarbeitung, die für
* einen Kotlin-Einsteiger zusätzliche "Magie" ohne unmittelbaren Lernwert wäre (Architektur-
* Dokument Abschnitt 13). Bei deutlichem Projektwachstum ist das später nachrüstbar.
*
* Alle Objekte hier sind bewusst `by lazy`: Sie werden erst beim ersten Zugriff erzeugt, nicht
* bereits beim App-Start – unnötig für eine Klasse, die ohnehin nur einmal pro Prozesslebenszeit
* instanziiert wird, aber ein günstiges, verbreitetes Kotlin-Idiom für "einmalig und träge
* initialisieren".
*/
class AppContainer(context: Context) {
private val database: AppDatabase by lazy { AppDatabase.getInstance(context) }
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) }
val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao())
}
val documentationRepository: DocumentationRepository by lazy {
DocumentationRepository(
documentationDao = database.documentationDao(),
blockStateDao = database.blockStateDao(),
checkItemStateDao = database.checkItemStateDao(),
photoDao = database.photoDao(),
signatureDao = database.signatureDao(),
photoStorage = photoStorage,
signatureFileStorage = signatureFileStorage,
db = database,
)
}
}
@@ -0,0 +1,17 @@
package de.medapp.wunddoku.di
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import de.medapp.wunddoku.WunddokuApplication
/**
* Kleiner Helfer, um innerhalb eines `viewModelFactory { initializer { ... } }`-Blocks an das
* [AppContainer] der [WunddokuApplication] zu kommen, ohne Hilt/Koin.
*
* Kotlin-Hinweis: `CreationExtras` ist der von Compose/Activity bereitgestellte Kontext, aus
* dem sich über den Standard-Schlüssel `APPLICATION_KEY` die aktuelle `Application`-Instanz
* auslesen lässt – der von Google empfohlene, moderne Weg für manuelles ViewModel-DI ohne
* Hilt (`viewModelFactory { initializer { MeinViewModel(extras.wunddokuApplication().container...) } }`).
*/
fun CreationExtras.wunddokuApplication(): WunddokuApplication =
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as WunddokuApplication
@@ -0,0 +1,73 @@
package de.medapp.wunddoku.domain.catalog
/**
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
*
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte").
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden.
*
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
* Abschnitt 2.3b.
*
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots).
*/
object DocumentationCatalog {
const val CATALOG_VERSION: Int = 1
val blocks: List<BlockDefinition> = listOf(
BlockDefinition(
id = "block_01",
title = "Block 1",
order = 1,
items = listOf(
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1),
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2),
),
),
BlockDefinition(
id = "block_02",
title = "Block 2",
order = 2,
items = listOf(
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1),
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
),
),
BlockDefinition(
id = "block_03",
title = "Block 3",
order = 3,
items = listOf(
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1),
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2),
),
),
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch.
)
fun findBlock(blockDefinitionId: String): BlockDefinition? =
blocks.firstOrNull { it.id == blockDefinitionId }
}
data class BlockDefinition(
val id: String,
val title: String,
val order: Int,
val items: List<CheckItemDefinition>,
)
data class CheckItemDefinition(
val id: String,
val label: String,
val order: Int,
)
@@ -0,0 +1,18 @@
package de.medapp.wunddoku.domain.model
/**
* Zustand einer einzelnen Checkbox innerhalb eines Dokumentationsblocks.
*
* [label] stammt aus dem zum Erstellungszeitpunkt der Dokumentation gültigen Snapshot
* (CheckItemState.labelSnapshot in der Datenbank), NICHT aus einer Laufzeit-Auflösung des
* aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Das stellt sicher, dass
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
* dargestellt werden. Siehe Architektur-Dokument Abschnitt 2.4.
*/
data class CheckItem(
val checkItemStateId: String,
val definitionId: String,
val label: String,
val order: Int,
val checked: Boolean,
)
@@ -0,0 +1,40 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
/**
* Eine vollständige Wunddokumentation inklusive aller Blöcke, Fotos und Unterschriften.
*
* [continuedFromDocumentationId] verweist auf die Vorgänger-Dokumentation, falls diese
* Dokumentation durch "Fortsetzen" entstanden ist (Architektur-Dokument Abschnitt 6) – rein
* informatives Metadatum, keine fachliche Pflichtlogik in V1.
*
* [catalogVersion] ist ebenfalls rein informativ (z. B. für spätere Auswertungen). Die
* korrekte historische Darstellung hängt NICHT von diesem Feld ab, sondern ausschließlich von
* den Label-/Titel-Snapshots in [DocumentationBlock]/[CheckItem] (Abschnitt 2.4).
*/
data class Documentation(
val id: String,
val patientId: String,
val status: DocumentationStatus,
val createdAt: Instant,
val updatedAt: Instant,
val continuedFromDocumentationId: String?,
val catalogVersion: Int,
val blocks: List<DocumentationBlock>,
val photos: List<Photo>,
val signatures: List<Signature>,
)
/**
* Schlanke Projektion für Listenansichten (Startscreen), ohne die vollständigen Blöcke zu
* laden – vermeidet unnötig teure Queries, wenn nur Übersichtsdaten benötigt werden.
*/
data class DocumentationSummary(
val id: String,
val patientId: String,
val patientFullName: String,
val status: DocumentationStatus,
val createdAt: Instant,
val updatedAt: Instant,
)
@@ -0,0 +1,26 @@
package de.medapp.wunddoku.domain.model
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
/**
* Ein einzelner Dokumentationsblock innerhalb einer Wunddokumentation (ca. 20 pro
* Dokumentation). [title] stammt wie bei [CheckItem.label] aus dem Erstellungs-Snapshot, nicht
* aus einer Laufzeit-Auflösung des Katalogs.
*
* [isEdited] wird bewusst NICHT in der Datenbank gespeichert, sondern hier als abgeleitete
* Eigenschaft berechnet ("computed property", kein eigenes Feld im Konstruktor). Das
* verhindert, dass ein gespeichertes Flag jemals vom tatsächlichen Inhalt abweichen kann.
* [BlockEditedPolicy] ist die einzige Stelle, die diese Regel definiert – sowohl die UI
* (Fortschrittsanzeige) als auch der spätere PDF-Export verwenden ausschließlich diese
* Eigenschaft. Siehe Architektur-Dokument Abschnitt 2.3c.
*/
data class DocumentationBlock(
val blockStateId: String,
val definitionId: String,
val title: String,
val order: Int,
val items: List<CheckItem>,
val freeText: String,
) {
val isEdited: Boolean get() = BlockEditedPolicy.isEdited(this)
}
@@ -0,0 +1,15 @@
package de.medapp.wunddoku.domain.model
/**
* Status einer Wunddokumentation.
*
* Bewusst ein einfaches `enum class` statt `sealed interface`/`sealed class`: Die Zustände
* tragen aktuell keine zustandsspezifischen Zusatzdaten, sondern stellen nur eine endliche
* Auswahl dar. `sealed interface` würde hier keinen Vorteil bringen und nur unnötige
* Komplexität hinzufügen. Eine Erweiterung, z. B. um CANCELLED, ist mit einem Enum genauso
* einfach möglich. Siehe Architektur-Dokument Abschnitt 2.5.
*/
enum class DocumentationStatus {
IN_PROGRESS,
COMPLETED,
}
@@ -0,0 +1,29 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
import java.time.LocalDate
/**
* Fachliches Patientenmodell. Bewusst unabhängig von Room- und Compose-Typen, damit sich die
* Persistenz (Room-Entity) oder die UI unabhängig voneinander weiterentwickeln können.
*
* Vor- und Nachname werden getrennt gespeichert statt als ein "name"-Feld: Das ermöglicht
* separate Suche/Sortierung nach Nachnamen (in Kliniken/Pflege üblich, z. B. "Nachname,
* Vorname"-Listendarstellung) und ist die robustere Variante für spätere Erweiterungen
* (z. B. Anrede + Nachname in Dokumenten), ohne den Namen nachträglich wieder aufteilen zu
* müssen.
*/
data class Patient(
val id: String,
val firstName: String,
val lastName: String,
val birthDate: LocalDate,
val address: String,
val phone1: String,
val phone2: String?,
val phone3: String?,
val createdAt: Instant,
val updatedAt: Instant,
) {
val fullName: String get() = "$firstName $lastName"
}
@@ -0,0 +1,17 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
import java.time.LocalDate
/**
* Schlanke Projektion für die Patientenliste (Architektur-Dokument Abschnitt 6), inklusive
* Datum der letzten Wunddokumentation. Getrennt vom vollständigen [Patient]-Modell, damit die
* Listen-Query nicht implizit an eine bestimmte Detaildarstellung gekoppelt ist.
*/
data class PatientListItem(
val id: String,
val fullName: String,
val birthDate: LocalDate,
val address: String,
val lastDocumentationAt: Instant?,
)
@@ -0,0 +1,19 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
/**
* Ein an eine Wunddokumentation angehängtes Foto.
*
* [fileName] ist ein relativer Pfad innerhalb des privaten internen App-Speichers
* (context.filesDir/photos/...), kein absoluter Pfad und keine Galerie-URI – die
* ausgewählten Bilder werden beim Import dauerhaft in den App-Speicher kopiert
* (Architektur-Dokument Abschnitt 11).
*/
data class Photo(
val id: String,
val documentationId: String,
val fileName: String,
val orderIndex: Int,
val createdAt: Instant,
)
@@ -0,0 +1,19 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
/**
* Eine digitale Unterschrift zu einer Wunddokumentation.
*
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später
* mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
* (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen
* App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11.
*/
data class Signature(
val id: String,
val documentationId: String,
val signerName: String?,
val fileName: String,
val createdAt: Instant,
)
@@ -0,0 +1,23 @@
package de.medapp.wunddoku.domain.usecase
import de.medapp.wunddoku.domain.model.DocumentationBlock
/**
* Zentrale, einzige Definition davon, wann ein Dokumentationsblock als "bearbeitet" gilt.
*
* Diese Regel wird an zwei Stellen verwendet, die zwingend dieselbe Definition teilen müssen:
* - UI: Fortschrittsanzeige, welche Blöcke bereits ausgefüllt wurden
* - PDF-Export: nur bearbeitete Blöcke werden ins PDF aufgenommen (Aufgabenstellung Abschnitt 12)
*
* Bewusst als eigenständiges Objekt statt als private Logik in ViewModel/Exporter, damit beide
* Stellen garantiert dasselbe Ergebnis liefern und die Regel an einer einzigen Stelle geändert
* werden kann, falls sich die fachliche Definition von "bearbeitet" später ändert.
*/
object BlockEditedPolicy {
fun isEdited(block: DocumentationBlock): Boolean {
val hasFreeText = block.freeText.isNotBlank()
val hasCheckedItem = block.items.any { it.checked }
return hasFreeText || hasCheckedItem
}
}
@@ -0,0 +1,65 @@
package de.medapp.wunddoku.media
import android.content.Context
import android.net.Uri
import java.io.File
import java.util.UUID
/**
* Verwaltet die physischen Bilddateien im internen App-Speicher (context.filesDir/photos/).
*
* WICHTIG (Phase-Hinweis): Diese Implementierung kopiert Bilder in Phase 2 zunächst
* unverändert (1:1-Kopie der ausgewählten Datei). Downsampling großer Smartphone-Fotos und
* EXIF-Rotation (Aufgabenstellung Abschnitt 10) werden erst in Phase 4 ergänzt, wenn der
* Photo Picker tatsächlich angebunden wird – das Interface ist aber bereits so geschnitten,
* dass diese Erweiterung [copyFrom] intern betrifft, ohne die Repository-Schicht zu ändern.
*
* Bewusst als Interface + Implementierung: erlaubt, in Unit-Tests eine Fake-Implementierung
* einzusetzen, ohne echtes Dateisystem anzufassen (Aufgabenstellung Abschnitt 21).
*/
interface PhotoStorage {
/** Kopiert das Bild von [sourceUri] in den internen Speicher und liefert den relativen Dateinamen. */
suspend fun copyFrom(sourceUri: Uri): String
/**
* Erstellt eine physisch eigenständige Kopie einer bereits gespeicherten Datei und liefert
* deren neuen Dateinamen. Wird beim "Fortsetzen" mit `copyPhotos = true` benötigt: Eine neue
* DB-Zeile, die auf dieselbe Datei wie das Original zeigt, wäre falsch – sonst würde das
* Löschen des Fotos in einer Dokumentation es auch in der anderen entfernen.
*/
suspend fun duplicate(fileName: String): String
/** Löscht die physische Datei. Wirft KEINE Exception, wenn die Datei nicht (mehr) existiert. */
suspend fun delete(fileName: String)
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen (z. B. zur Anzeige). */
fun resolve(fileName: String): File
}
class InternalPhotoStorage(private val context: Context) : PhotoStorage {
private val photosDir: File by lazy {
File(context.filesDir, "photos").apply { mkdirs() }
}
override suspend fun copyFrom(sourceUri: Uri): String {
val fileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(photosDir, fileName)
context.contentResolver.openInputStream(sourceUri)?.use { input ->
targetFile.outputStream().use { output -> input.copyTo(output) }
} ?: error("Konnte ausgewähltes Bild nicht öffnen (Uri nicht mehr gültig?): $sourceUri")
return fileName
}
override suspend fun duplicate(fileName: String): String {
val newFileName = "${UUID.randomUUID()}.jpg"
resolve(fileName).copyTo(File(photosDir, newFileName), overwrite = false)
return newFileName
}
override suspend fun delete(fileName: String) {
resolve(fileName).delete()
}
override fun resolve(fileName: String): File = File(photosDir, fileName)
}
@@ -0,0 +1,40 @@
package de.medapp.wunddoku.signature
import android.content.Context
import android.graphics.Bitmap
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
/**
* Verwaltet die physischen Signatur-PNGs im internen App-Speicher
* (context.filesDir/signatures/). Das eigentliche Zeichnen (SignaturePad-Composable) folgt in
* Phase 4; diese Klasse übernimmt nur die Dateiverwaltung, damit DocumentationRepository
* bereits jetzt gegen eine stabile Schnittstelle arbeiten kann.
*/
interface SignatureFileStorage {
suspend fun save(bitmap: Bitmap): String
suspend fun delete(fileName: String)
fun resolve(fileName: String): File
}
class InternalSignatureFileStorage(private val context: Context) : SignatureFileStorage {
private val signaturesDir: File by lazy {
File(context.filesDir, "signatures").apply { mkdirs() }
}
override suspend fun save(bitmap: Bitmap): String {
val fileName = "${UUID.randomUUID()}.png"
FileOutputStream(File(signaturesDir, fileName)).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
return fileName
}
override suspend fun delete(fileName: String) {
resolve(fileName).delete()
}
override fun resolve(fileName: String): File = File(signaturesDir, fileName)
}
@@ -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)
}
}
@@ -0,0 +1,167 @@
package de.medapp.wunddoku.ui.documentation.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class DocumentationEditUiState(
val documentation: Documentation? = null,
val currentBlockIndex: Int = 0,
val isLoading: Boolean = true,
val error: String? = null,
) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0
}
/**
* Hält den kompletten Bearbeitungszustand einer Wunddokumentation und setzt die Autosave-
* Strategie aus dem Architektur-Dokument (Abschnitt 9) um:
* - Checkbox-Änderungen: sofort persistiert
* - Freitext-Änderungen: UI sofort aktualisiert, Persistierung über 300ms Debounce
*
* WICHTIG (siehe Architektur-Dokument Abschnitt 9.2): `onCleared()` wird bewusst NICHT als
* Garantie für das Flushen ausstehender Änderungen verwendet, da Android dessen Aufruf bei
* einem harten Prozessabbruch nicht garantiert. [flushPendingChanges] wird stattdessen vom
* Screen aus einem `Lifecycle.Event.ON_STOP`-Observer aufgerufen – das deckt den häufigen Fall
* (App gewechselt, Bildschirm gesperrt) ab, ist aber ausdrücklich keine Garantie für den
* seltenen Fall eines sofortigen Systemkills.
*/
class DocumentationEditViewModel(
private val documentationId: String,
private val repository: DocumentationRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(DocumentationEditUiState())
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
private val freeTextChanges = MutableSharedFlow<Pair<String, String>>(extraBufferCapacity = 64)
/** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */
private val pendingFreeText = mutableMapOf<String, String>()
init {
viewModelScope.launch { loadDocumentation() }
observeFreeTextChanges()
}
private suspend fun loadDocumentation() {
_uiState.update { it.copy(isLoading = true, error = null) }
val documentation = repository.getFullDocumentation(documentationId)
_uiState.update {
if (documentation != null) {
it.copy(documentation = documentation, isLoading = false)
} else {
it.copy(isLoading = false, error = "Dokumentation nicht gefunden.")
}
}
}
@OptIn(FlowPreview::class)
private fun observeFreeTextChanges() {
viewModelScope.launch {
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) ->
persistFreeText(blockStateId, text)
}
}
}
private suspend fun persistFreeText(blockStateId: String, text: String) {
repository.updateFreeText(blockStateId, text)
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId)
}
fun onFreeTextChanged(blockStateId: String, text: String) {
updateLocalFreeText(blockStateId, text)
pendingFreeText[blockStateId] = text
freeTextChanges.tryEmit(blockStateId to text)
}
private fun updateLocalFreeText(blockStateId: String, text: String) {
_uiState.update { state ->
val documentation = state.documentation ?: return@update state
val updatedBlocks = documentation.blocks.map { block ->
if (block.blockStateId == blockStateId) block.copy(freeText = text) else block
}
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
}
}
fun onCheckedChanged(checkItemStateId: String, checked: Boolean) {
// 1) UI sofort aktualisieren
_uiState.update { state ->
val documentation = state.documentation ?: return@update state
val updatedBlocks = documentation.blocks.map { block ->
val updatedItems = block.items.map { item ->
if (item.checkItemStateId == checkItemStateId) item.copy(checked = checked) else item
}
block.copy(items = updatedItems)
}
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
}
// 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE)
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
}
fun onNextBlock() {
_uiState.update { state ->
val lastIndex = state.blockCount - 1
if (lastIndex < 0) return@update state
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
}
}
fun onPreviousBlock() {
_uiState.update { state ->
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
}
}
/**
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
* hartem Prozessabbruch.
*/
fun flushPendingChanges() {
if (pendingFreeText.isEmpty()) return
val toFlush = pendingFreeText.toMap()
pendingFreeText.clear()
viewModelScope.launch {
toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) }
}
}
companion object {
/** Debounce-Fenster für Freitext-Autosave. Siehe Architektur-Dokument Abschnitt 9.3:
* bewusst 300ms statt 500ms, da bei dieser kleinen lokalen SQLite-Datenbank Robustheit
* gegenüber reiner Schreibersparnis priorisiert werden darf. */
private const val FREE_TEXT_DEBOUNCE_MS = 300L
fun factory() = viewModelFactory {
initializer {
val container = wunddokuApplication().container
val savedStateHandle = createSavedStateHandle()
val route = savedStateHandle.toRoute<Screen.DocumentationEdit>()
DocumentationEditViewModel(
documentationId = route.documentationId,
repository = container.documentationRepository,
)
}
}
}
}
@@ -0,0 +1,186 @@
package de.medapp.wunddoku.ui.documentation.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.DocumentationSummary
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.ZoneId
private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
/**
* Startscreen der App (Aufgabenstellung Abschnitt 5): Liste aller Wunddokumentationen,
* durchsuchbar, mit den Aktionen Bearbeiten/Fortsetzen/Löschen (mit Bestätigung).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentationListScreen(
onOpenDocumentation: (documentationId: String) -> Unit,
onNewDocumentation: () -> Unit,
onOpenPatients: () -> Unit,
viewModel: DocumentationListViewModel = viewModel(factory = DocumentationListViewModel.factory()),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.documentation_list_title)) },
actions = {
IconButton(onClick = onOpenPatients) {
Icon(Icons.Filled.People, contentDescription = stringResource(R.string.patient_list_title))
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(onClick = onNewDocumentation) {
Icon(Icons.Filled.Add, contentDescription = null)
Text(stringResource(R.string.documentation_list_new))
}
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField(
value = uiState.searchQuery,
onValueChange = viewModel::onSearchQueryChanged,
label = { Text(stringResource(R.string.documentation_list_search_hint)) },
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
if (uiState.items.isEmpty()) {
Text(
text = stringResource(R.string.documentation_list_empty),
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(uiState.items, key = { it.id }) { item ->
DocumentationListItemCard(
item = item,
onOpen = { onOpenDocumentation(item.id) },
onContinue = {
viewModel.continueDocumentation(item.id) { newId -> onOpenDocumentation(newId) }
},
onDeleteRequested = { pendingDeleteId = item.id },
)
}
}
}
}
}
val deleteId = pendingDeleteId
if (deleteId != null) {
AlertDialog(
onDismissRequest = { pendingDeleteId = null },
title = { Text(stringResource(R.string.documentation_delete_confirm_title)) },
text = { Text(stringResource(R.string.documentation_delete_confirm_text)) },
confirmButton = {
TextButton(onClick = {
viewModel.deleteDocumentation(deleteId)
pendingDeleteId = null
}) { Text(stringResource(R.string.action_confirm)) }
},
dismissButton = {
TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DocumentationListItemCard(
item: DocumentationSummary,
onOpen: () -> Unit,
onContinue: () -> Unit,
onDeleteRequested: () -> Unit,
) {
var menuExpanded by remember { mutableStateOf(false) }
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(item.patientFullName, style = MaterialTheme.typography.titleMedium)
Text(
"Erstellt: ${item.createdAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall,
)
if (item.updatedAt != item.createdAt) {
Text(
"Geändert: ${item.updatedAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall,
)
}
if (item.status == DocumentationStatus.COMPLETED) {
Text("Abgeschlossen", style = MaterialTheme.typography.labelSmall)
}
}
Column {
IconButton(onClick = { menuExpanded = true }) {
Text("⋮")
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.documentation_action_edit)) },
onClick = { menuExpanded = false; onOpen() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.documentation_action_continue)) },
onClick = { menuExpanded = false; onContinue() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.documentation_action_delete)) },
onClick = { menuExpanded = false; onDeleteRequested() },
)
}
}
}
}
}
@@ -0,0 +1,74 @@
package de.medapp.wunddoku.ui.documentation.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.DocumentationSummary
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class DocumentationListUiState(
val items: List<DocumentationSummary> = emptyList(),
val searchQuery: String = "",
)
/**
* Kotlin-Hinweis zu `StateFlow`/`Flow`/`flatMapLatest`: [searchQuery] ist der Eingabetext, aus
* dem wir per `flatMapLatest` jeweils den passenden Datenbank-`Flow` ableiten (entweder "alle"
* oder "gefiltert nach Suchtext") – ändert sich der Suchtext erneut, während die vorherige
* Datenbankabfrage noch läuft, bricht `flatMapLatest` diese automatisch ab und wechselt zur
* neuen. Das verhindert veraltete Suchergebnisse ohne manuelles Abbrechen von Coroutines.
*/
class DocumentationListViewModel(
private val repository: DocumentationRepository,
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class)
private val items = searchQuery.flatMapLatest { query ->
if (query.isBlank()) repository.observeAll() else repository.search(query)
}
val uiState: StateFlow<DocumentationListUiState> = combine(items, searchQuery) { list, query ->
DocumentationListUiState(items = list, searchQuery = query)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DocumentationListUiState(),
)
fun onSearchQueryChanged(query: String) {
searchQuery.value = query
}
fun deleteDocumentation(documentationId: String) {
viewModelScope.launch { repository.delete(documentationId) }
}
fun continueDocumentation(documentationId: String, onCreated: (newDocumentationId: String) -> Unit) {
viewModelScope.launch {
val newDocumentation = repository.continueDocumentation(documentationId)
onCreated(newDocumentation.id)
}
}
companion object {
fun factory() = viewModelFactory {
initializer {
val container = wunddokuApplication().container
DocumentationListViewModel(container.documentationRepository)
}
}
}
}
@@ -0,0 +1,72 @@
package de.medapp.wunddoku.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen
import de.medapp.wunddoku.ui.documentation.list.DocumentationListScreen
import de.medapp.wunddoku.ui.patients.form.PatientFormScreen
import de.medapp.wunddoku.ui.patients.list.PatientListScreen
/**
* Navigations-Graph gemäß Architektur-Dokument Abschnitt 8:
*
* DocumentationList -> PatientForm(null) -> DocumentationEdit(neue Id)
* DocumentationList -> PatientList -> PatientForm(id) / DocumentationEdit (bearbeiten/fortsetzen)
* DocumentationEdit -> zurück zu DocumentationList (nach Abschluss/PDF)
*/
@Composable
fun WunddokuNavGraph() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.DocumentationList) {
composable<Screen.DocumentationList> {
DocumentationListScreen(
onOpenDocumentation = { id -> navController.navigate(Screen.DocumentationEdit(id)) },
onNewDocumentation = {
navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = true))
},
onOpenPatients = { navController.navigate(Screen.PatientList) },
)
}
composable<Screen.PatientList> {
PatientListScreen(
onEditPatient = { id ->
navController.navigate(Screen.PatientForm(patientId = id, startsNewDocumentation = false))
},
onNewPatient = {
navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = false))
},
onOpenDocumentation = { documentationId -> navController.navigate(Screen.DocumentationEdit(documentationId)) },
onBack = { navController.popBackStack() },
)
}
composable<Screen.PatientForm> {
PatientFormScreen(
onPatientSavedOnly = { navController.popBackStack() },
onDocumentationCreated = { documentationId ->
// Das Formular wird aus dem Back-Stack entfernt, damit "Zurück" aus der
// Dokumentation nicht wieder im Formular landet.
navController.navigate(Screen.DocumentationEdit(documentationId)) {
popUpTo<Screen.DocumentationList> { inclusive = false }
}
},
onCancel = { navController.popBackStack() },
)
}
composable<Screen.DocumentationEdit> {
DocumentationEditScreen(
onFinished = {
navController.navigate(Screen.DocumentationList) {
popUpTo<Screen.DocumentationList> { inclusive = true }
}
},
)
}
}
}
@@ -0,0 +1,41 @@
package de.medapp.wunddoku.ui.navigation
import kotlinx.serialization.Serializable
/**
* Typsichere Navigations-Routen (aktueller Standard von Navigation Compose seit 2.8, statt
* loser String-Routen mit manuellem Argument-Parsing). Tippfehler in Routen-Namen fallen damit
* bereits beim Kompilieren auf statt erst zur Laufzeit.
*
* Kotlin-Hinweis: `sealed interface` fasst die möglichen Bildschirme zu einer geschlossenen,
* dem Compiler bekannten Menge zusammen (siehe auch Erläuterung bei DocumentationStatus, wo
* bewusst KEIN sealed interface verwendet wird, weil dort keine zustandsspezifischen Daten
* nötig sind) – hier tragen die Varianten dagegen unterschiedliche, jeweils eigene Argumente
* (z. B. `documentationId`), was `sealed interface` hier zur passenden Wahl macht.
* `@Serializable` (kotlinx.serialization) ist die Voraussetzung dafür, dass Navigation Compose
* eine Route-Instanz automatisch in einen Navigations-Zustand kodieren/dekodieren kann.
*/
sealed interface Screen {
@Serializable
data object DocumentationList : Screen
@Serializable
data object PatientList : Screen
/**
* [startsNewDocumentation] unterscheidet zwei fachlich unterschiedliche Abläufe, die sich
* dasselbe Formular teilen (Aufgabenstellung Abschnitt 7 Schritt 1 vs. Abschnitt 6
* "Patient bearbeiten"):
* - `true`: Formular ist Schritt 1 einer neuen Wunddokumentation ("Neu" auf der
* Dokumentationsliste). Nach dem Speichern/Duplikat-Abgleich wird direkt eine neue,
* leere Dokumentation angelegt und geöffnet.
* - `false`: reines Bearbeiten der Patienten-Stammdaten (aus der Patientenliste). Nach dem
* Speichern geht es zurück zur Patientenliste, ohne eine Dokumentation anzulegen.
*/
@Serializable
data class PatientForm(val patientId: String? = null, val startsNewDocumentation: Boolean = false) : Screen
@Serializable
data class DocumentationEdit(val documentationId: String) : Screen
}
@@ -0,0 +1,179 @@
package de.medapp.wunddoku.ui.patients.form
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R
/**
* Formular für Schritt 1 einer neuen Wunddokumentation (Aufgabenstellung Abschnitt 7) UND für
* das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über
* [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm].
*
* Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT)
* erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle
* Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatientFormScreen(
onPatientSavedOnly: () -> Unit,
onDocumentationCreated: (documentationId: String) -> Unit,
onCancel: () -> Unit,
viewModel: PatientFormViewModel = viewModel(factory = PatientFormViewModel.factory()),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState.patientSaved) {
if (uiState.patientSaved) onPatientSavedOnly()
}
LaunchedEffect(uiState.createdDocumentationId) {
uiState.createdDocumentationId?.let { onDocumentationCreated(it) }
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(
if (uiState.isEditing) R.string.patient_form_title_edit else R.string.patient_form_title_new
)
)
},
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
},
) { padding ->
if (uiState.isLoading) {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = uiState.firstName,
onValueChange = viewModel::onFirstNameChanged,
label = { Text(stringResource(R.string.patient_form_first_name)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.lastName,
onValueChange = viewModel::onLastNameChanged,
label = { Text(stringResource(R.string.patient_form_last_name)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.birthDateText,
onValueChange = viewModel::onBirthDateTextChanged,
label = { Text(stringResource(R.string.patient_form_birth_date)) },
placeholder = { Text("JJJJ-MM-TT") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.address,
onValueChange = viewModel::onAddressChanged,
label = { Text(stringResource(R.string.patient_form_address)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.phone1,
onValueChange = viewModel::onPhone1Changed,
label = { Text(stringResource(R.string.patient_form_phone1)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.phone2,
onValueChange = viewModel::onPhone2Changed,
label = { Text(stringResource(R.string.patient_form_phone2)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = uiState.phone3,
onValueChange = viewModel::onPhone3Changed,
label = { Text(stringResource(R.string.patient_form_phone3)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
uiState.validationError?.let { error ->
Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
}
Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.patient_form_save))
}
}
}
val duplicate = uiState.duplicateWarning
if (duplicate != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDuplicateWarning,
title = { Text("Patient existiert bereits") },
text = {
Text(
"Es existiert bereits ein Patient mit diesem Namen und Geburtsdatum " +
"(${duplicate.fullName}, ${duplicate.birthDate}). Möchtest du diesen " +
"vorhandenen Patienten verwenden, oder trotzdem einen neuen Datensatz anlegen?"
)
},
confirmButton = {
TextButton(onClick = viewModel::useExistingPatient) {
Text("Vorhandenen verwenden")
}
},
dismissButton = {
TextButton(onClick = viewModel::createAnyway) {
Text("Trotzdem neu anlegen")
}
},
)
}
}
@@ -0,0 +1,184 @@
package de.medapp.wunddoku.ui.patients.form
import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
data class PatientFormUiState(
val isEditing: Boolean = false,
val isLoading: Boolean = true,
val firstName: String = "",
val lastName: String = "",
/** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */
val birthDateText: String = "",
val address: String = "",
val phone1: String = "",
val phone2: String = "",
val phone3: String = "",
val validationError: String? = null,
/** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */
val duplicateWarning: Patient? = null,
/** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */
val patientSaved: Boolean = false,
/** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */
val createdDocumentationId: String? = null,
)
/**
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab
* (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6).
*/
class PatientFormViewModel(
private val patientId: String?,
private val startsNewDocumentation: Boolean,
private val repository: PatientRepository,
private val documentationRepository: DocumentationRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null))
val uiState: StateFlow<PatientFormUiState> = _uiState.asStateFlow()
init {
if (patientId != null) {
viewModelScope.launch {
val patient = repository.getById(patientId)
_uiState.update {
if (patient != null) {
it.copy(
isLoading = false,
firstName = patient.firstName,
lastName = patient.lastName,
birthDateText = patient.birthDate.toString(),
address = patient.address,
phone1 = patient.phone1,
phone2 = patient.phone2.orEmpty(),
phone3 = patient.phone3.orEmpty(),
)
} else {
it.copy(isLoading = false, validationError = "Patient nicht gefunden.")
}
}
}
} else {
_uiState.update { it.copy(isLoading = false) }
}
}
fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) }
fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) }
fun onBirthDateTextChanged(value: String) = _uiState.update { it.copy(birthDateText = value) }
fun onAddressChanged(value: String) = _uiState.update { it.copy(address = value) }
fun onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) }
fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) }
fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) }
fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
/** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */
fun useExistingPatient() {
val existing = _uiState.value.duplicateWarning ?: return
_uiState.update { it.copy(duplicateWarning = null) }
viewModelScope.launch { finish(existing.id) }
}
/** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */
fun createAnyway() {
_uiState.update { it.copy(duplicateWarning = null) }
save(skipDuplicateCheck = true)
}
fun save(skipDuplicateCheck: Boolean = false) {
val state = _uiState.value
val birthDate = try {
LocalDate.parse(state.birthDateText.trim())
} catch (e: Exception) {
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") }
return
}
if (state.firstName.isBlank() || state.lastName.isBlank()) {
_uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") }
return
}
if (state.address.isBlank() || state.phone1.isBlank()) {
_uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") }
return
}
viewModelScope.launch {
if (!state.isEditing && !skipDuplicateCheck) {
val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate)
if (existing != null) {
_uiState.update { it.copy(duplicateWarning = existing, validationError = null) }
return@launch
}
}
val patient = Patient(
id = patientId ?: repository.newId(),
firstName = state.firstName.trim(),
lastName = state.lastName.trim(),
birthDate = birthDate,
address = state.address.trim(),
phone1 = state.phone1.trim(),
phone2 = state.phone2.trim().ifBlank { null },
phone3 = state.phone3.trim().ifBlank { null },
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
updatedAt = Instant.EPOCH,
)
val saved = repository.upsert(patient)
_uiState.update { it.copy(validationError = null) }
finish(saved.id)
}
}
/**
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder
* nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
*/
private suspend fun finish(resolvedPatientId: String) {
if (startsNewDocumentation) {
val documentation = documentationRepository.createEmpty(resolvedPatientId)
_uiState.update { it.copy(createdDocumentationId = documentation.id) }
} else {
_uiState.update { it.copy(patientSaved = true) }
}
}
companion object {
fun factory() = viewModelFactory {
initializer {
val container = wunddokuApplication().container
// createSavedStateHandle() ist die von Google empfohlene Art, innerhalb eines
// viewModelFactory-Initializers an das SavedStateHandle zu kommen; toRoute()
// dekodiert daraus direkt die typsichere Navigations-Route (Screen.PatientForm),
// statt Argumente manuell als lose Strings auszulesen.
val savedStateHandle = createSavedStateHandle()
val route = savedStateHandle.toRoute<Screen.PatientForm>()
PatientFormViewModel(
patientId = route.patientId,
startsNewDocumentation = route.startsNewDocumentation,
repository = container.patientRepository,
documentationRepository = container.documentationRepository,
)
}
}
}
}
@@ -0,0 +1,201 @@
package de.medapp.wunddoku.ui.patients.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.PatientListItem
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.ZoneId
private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
/** Separate Patientenansicht (Aufgabenstellung Abschnitt 6). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatientListScreen(
onEditPatient: (patientId: String) -> Unit,
onNewPatient: () -> Unit,
onOpenDocumentation: (documentationId: String) -> Unit,
onBack: () -> Unit,
viewModel: PatientListViewModel = viewModel(factory = PatientListViewModel.factory()),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.patient_list_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(onClick = onNewPatient) {
Icon(Icons.Filled.Add, contentDescription = null)
Text(stringResource(R.string.patient_list_new))
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField(
value = uiState.searchQuery,
onValueChange = viewModel::onSearchQueryChanged,
label = { Text(stringResource(R.string.patient_list_search_hint)) },
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
if (uiState.items.isEmpty()) {
Text(
text = stringResource(R.string.patient_list_empty),
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(uiState.items, key = { it.id }) { item ->
PatientListItemCard(
item = item,
onEdit = { onEditPatient(item.id) },
onDeleteRequested = { viewModel.requestDelete(item.id) },
onNewDocumentation = {
viewModel.createDocumentation(item.id) { docId -> onOpenDocumentation(docId) }
},
onContinueDocumentation = {
viewModel.continueDocumentation(item.id) { docId ->
if (docId != null) {
onOpenDocumentation(docId)
} else {
coroutineScope.launch {
snackbarHostState.showSnackbar(
"Für diesen Patienten existiert noch keine Dokumentation zum Fortsetzen."
)
}
}
}
},
)
}
}
}
}
}
if (uiState.deleteBlockedForPatientId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeleteBlockedDialog,
title = { Text(stringResource(R.string.patient_delete_blocked_title)) },
text = { Text(stringResource(R.string.patient_delete_blocked_text)) },
confirmButton = {
TextButton(onClick = viewModel::dismissDeleteBlockedDialog) {
Text(stringResource(R.string.action_confirm))
}
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PatientListItemCard(
item: PatientListItem,
onEdit: () -> Unit,
onDeleteRequested: () -> Unit,
onNewDocumentation: () -> Unit,
onContinueDocumentation: () -> Unit,
) {
var menuExpanded by remember { mutableStateOf(false) }
Card(onClick = onEdit, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(item.fullName, style = MaterialTheme.typography.titleMedium)
Text(
"Geburtsdatum: ${item.birthDate.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall,
)
Text(item.address, style = MaterialTheme.typography.bodySmall)
val lastDoc = item.lastDocumentationAt
if (lastDoc != null) {
Text(
"Letzte Dokumentation: ${lastDoc.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
style = MaterialTheme.typography.labelSmall,
)
}
}
Column {
IconButton(onClick = { menuExpanded = true }) {
Text("⋮")
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.documentation_action_edit)) },
onClick = { menuExpanded = false; onEdit() },
)
DropdownMenuItem(
text = { Text("Neue Dokumentation") },
onClick = { menuExpanded = false; onNewDocumentation() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.documentation_action_continue)) },
onClick = { menuExpanded = false; onContinueDocumentation() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.documentation_action_delete)) },
onClick = { menuExpanded = false; onDeleteRequested() },
)
}
}
}
}
}
@@ -0,0 +1,106 @@
package de.medapp.wunddoku.ui.patients.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.PatientListItem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class PatientListUiState(
val items: List<PatientListItem> = emptyList(),
val searchQuery: String = "",
/** != null, solange der Bestätigungsdialog "Löschen nicht möglich" angezeigt werden soll. */
val deleteBlockedForPatientId: String? = null,
)
class PatientListViewModel(
private val repository: PatientRepository,
private val documentationRepository: DocumentationRepository,
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private val deleteBlockedForPatientId = MutableStateFlow<String?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
private val items = searchQuery.flatMapLatest { query ->
if (query.isBlank()) repository.observeAll() else repository.search(query)
}
val uiState: StateFlow<PatientListUiState> =
combine(items, searchQuery, deleteBlockedForPatientId) { list, query, blockedId ->
PatientListUiState(items = list, searchQuery = query, deleteBlockedForPatientId = blockedId)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = PatientListUiState(),
)
fun onSearchQueryChanged(query: String) {
searchQuery.value = query
}
/**
* Löschversuch (Aufgabenstellung Abschnitt 6/20): Prüft zuerst, ob noch Dokumentationen zu
* diesem Patienten existieren, statt die Löschung blind zu versuchen und eine rohe
* Datenbank-Exception (ON DELETE RESTRICT) abfangen zu müssen. So kann eine verständliche
* Meldung angezeigt werden, statt eines technischen Fehlers.
*/
fun requestDelete(patientId: String) {
viewModelScope.launch {
if (repository.hasDocumentations(patientId)) {
deleteBlockedForPatientId.update { patientId }
} else {
repository.delete(patientId)
}
}
}
fun dismissDeleteBlockedDialog() {
deleteBlockedForPatientId.value = null
}
/**
* "neue Wunddokumentation für Patient erstellen" (Aufgabenstellung Abschnitt 6): Der
* Patient existiert bereits, es ist deshalb KEIN Stammdatenformular nötig – anders als beim
* Weg über die Dokumentationsliste ("Neu"), wo der Patient ggf. erst angelegt wird.
*/
fun createDocumentation(patientId: String, onCreated: (documentationId: String) -> Unit) {
viewModelScope.launch {
val documentation = documentationRepository.createEmpty(patientId)
onCreated(documentation.id)
}
}
/**
* "Behandlung/Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 6). Liefert `null` an
* [onCreated], falls der Patient noch keine Dokumentation besitzt – die UI zeigt in diesem
* Fall einen verständlichen Hinweis statt eines technischen Fehlers.
*/
fun continueDocumentation(patientId: String, onCreated: (documentationId: String?) -> Unit) {
viewModelScope.launch {
val documentation = documentationRepository.continueLatestForPatient(patientId)
onCreated(documentation?.id)
}
}
companion object {
fun factory() = viewModelFactory {
initializer {
val container = wunddokuApplication().container
PatientListViewModel(container.patientRepository, container.documentationRepository)
}
}
}
}
@@ -0,0 +1,40 @@
package de.medapp.wunddoku.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
/**
* Bewusst schlichtes Material-3-Theme ohne individuelle Markenfarben – für dieses Projekt gab
* es keine gestalterische Vorgabe. Dynamic Color (aus dem Systemwallpaper abgeleitete Farben,
* ab Android 12) wird verwendet, wo verfügbar, sonst ein neutrales Standard-Farbschema.
*/
@Composable
fun WunddokuTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Schlichtes, neutrales Platzhalter-Icon (Kreuz-Symbol), bis ein echtes Branding-Icon
geliefert wird. Als Vektor-Grafik statt PNG, damit kein separates Grafik-Tool nötig ist. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M48,24 H60 V48 H84 V60 H60 V84 H48 V60 H24 V48 H48 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B5E56</color>
</resources>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">VulneraDoc</string>
<string name="documentation_list_title">Wunddokumentationen</string>
<string name="documentation_list_search_hint">Patient oder Geburtsdatum suchen</string>
<string name="documentation_list_empty">Noch keine Wunddokumentationen vorhanden.</string>
<string name="documentation_list_new">Neue Dokumentation</string>
<string name="documentation_action_edit">Bearbeiten</string>
<string name="documentation_action_continue">Fortsetzen</string>
<string name="documentation_action_delete">Löschen</string>
<string name="documentation_delete_confirm_title">Dokumentation löschen?</string>
<string name="documentation_delete_confirm_text">Diese Wunddokumentation inklusive aller Fotos und Unterschriften wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="patient_list_title">Patienten</string>
<string name="patient_list_search_hint">Name oder Geburtsdatum suchen</string>
<string name="patient_list_empty">Noch keine Patienten vorhanden.</string>
<string name="patient_list_new">Neuer Patient</string>
<string name="patient_delete_blocked_title">Löschen nicht möglich</string>
<string name="patient_delete_blocked_text">Für diesen Patienten existieren noch Wunddokumentationen. Diese müssen zuerst gelöscht werden, bevor der Patient gelöscht werden kann.</string>
<string name="patient_form_title_new">Neuer Patient</string>
<string name="patient_form_title_edit">Patient bearbeiten</string>
<string name="patient_form_first_name">Vorname</string>
<string name="patient_form_last_name">Nachname</string>
<string name="patient_form_birth_date">Geburtsdatum</string>
<string name="patient_form_address">Adresse</string>
<string name="patient_form_phone1">Telefonnummer 1</string>
<string name="patient_form_phone2">Telefonnummer 2 (optional)</string>
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
<string name="patient_form_save">Speichern</string>
<string name="documentation_edit_position">%1$d / %2$d</string>
<string name="documentation_edit_previous">Zurück</string>
<string name="documentation_edit_next">Weiter</string>
<string name="documentation_edit_free_text_label">Freitext</string>
<string name="action_cancel">Abbrechen</string>
<string name="action_confirm">Bestätigen</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Reines "Platzhalter"-Theme für das Manifest, bevor Compose übernimmt. Die eigentliche
Gestaltung (Farben, Typografie) erfolgt ausschließlich über WunddokuTheme in Compose. -->
<style name="Theme.Wunddoku" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
android:allowBackup="false" im Manifest deaktiviert Auto-Backup bereits vollständig.
Diese Datei ist eine zusätzliche, explizite Absicherung für Android 12+ (Device-to-Device-
Transfer beim Gerätewechsel), damit auch dieser Weg keine Patientendaten überträgt.
Siehe Abschnitt "Wichtige Android-spezifische Entscheidungen" der Architektur.
-->
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapability="true">
<exclude domain="database" />
<exclude domain="file" />
<exclude domain="sharedpref" />
</cloud-backup>
<device-transfer>
<exclude domain="database" />
<exclude domain="file" />
<exclude domain="sharedpref" />
</device-transfer>
</data-extraction-rules>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
intern (Abschnitt "Storage-Strategie" der Architektur, v3).
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" />
<files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" />
</paths>
+10
View File
@@ -0,0 +1,10 @@
// Root-Build-Datei. Die eigentliche Modul-Konfiguration liegt in app/build.gradle.kts.
// Hier werden nur die Plugin-Versionen zentral deklariert (apply false = im Root nicht
// direkt angewendet, sondern nur als Version für die Module verfügbar gemacht).
plugins {
id("com.android.application") version "8.9.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" apply false
id("com.google.devtools.ksp") version "2.1.20-1.0.31" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+3
View File
@@ -0,0 +1,3 @@
#This file is generated by updateDaemonJvm
toolchainVendor=microsoft
toolchainVersion=17
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+21
View File
@@ -0,0 +1,21 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "VulneraDoc"
include(":app")