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