CORE-001 Android-Projekt anlegen Feature

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