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
@@ -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,
)
}