CORE-001 Android-Projekt anlegen Feature
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
import de.medapp.wunddoku.domain.model.CheckItem
|
||||
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||
|
||||
/**
|
||||
* Bearbeitungsansicht für eine Wunddokumentation (Aufgabenstellung Abschnitt 7 Schritt 2,
|
||||
* Abschnitt 8). Zeigt in Phase 2 einen Block nach dem anderen mit Vor-/Zurück-Buttons und
|
||||
* Positionsanzeige ("7 / 20"). Die von der Aufgabenstellung bevorzugte Swipe-Geste zwischen
|
||||
* Blöcken sowie Fotos/Signatur/PDF-Export folgen in den Phasen 3–5.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentationEditScreen(
|
||||
onFinished: () -> Unit,
|
||||
viewModel: DocumentationEditViewModel = viewModel(factory = DocumentationEditViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
|
||||
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). Siehe Architektur-Dokument
|
||||
// Abschnitt 9.2: KEINE Garantie bei hartem Prozessabbruch, aber sinnvolle zusätzliche
|
||||
// Absicherung für den häufigen Fall.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
viewModel.flushPendingChanges()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (uiState.blockCount > 0) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.documentation_edit_position,
|
||||
uiState.currentBlockIndex + 1,
|
||||
uiState.blockCount,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// PDF-Export/Unterschrift folgen in Phase 4/5. "Fertig" beendet die
|
||||
// Bearbeitung für V1 bereits jetzt (Inhalte sind ohnehin per Autosave
|
||||
// gesichert, kein expliziter Speichern-Button nötig).
|
||||
Button(onClick = onFinished) { Text("Fertig") }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
when {
|
||||
uiState.isLoading -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
uiState.error != null -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||
) { Text(uiState.error!!, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
else -> {
|
||||
val documentation = uiState.documentation
|
||||
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex)
|
||||
if (documentation == null || currentBlock == null) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
|
||||
Text("Diese Dokumentation enthält keine Blöcke.")
|
||||
}
|
||||
} else {
|
||||
DocumentationBlockContent(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
block = currentBlock,
|
||||
onCheckedChanged = viewModel::onCheckedChanged,
|
||||
onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) },
|
||||
canGoBack = uiState.currentBlockIndex > 0,
|
||||
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
|
||||
onPrevious = viewModel::onPreviousBlock,
|
||||
onNext = viewModel::onNextBlock,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentationBlockContent(
|
||||
modifier: Modifier = Modifier,
|
||||
block: DocumentationBlock,
|
||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||
onFreeTextChanged: (String) -> Unit,
|
||||
canGoBack: Boolean,
|
||||
canGoNext: Boolean,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(block.title, style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
block.items.forEach { item -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = block.freeText,
|
||||
onValueChange = onFreeTextChanged,
|
||||
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
OutlinedButton(onClick = onPrevious, enabled = canGoBack) {
|
||||
Text(stringResource(R.string.documentation_edit_previous))
|
||||
}
|
||||
Button(onClick = onNext, enabled = canGoNext) {
|
||||
Text(stringResource(R.string.documentation_edit_next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckItemRow(
|
||||
item: CheckItem,
|
||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
|
||||
)
|
||||
Text(item.label)
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.navigation.toRoute
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.model.Documentation
|
||||
import de.medapp.wunddoku.ui.navigation.Screen
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DocumentationEditUiState(
|
||||
val documentation: Documentation? = null,
|
||||
val currentBlockIndex: Int = 0,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält den kompletten Bearbeitungszustand einer Wunddokumentation und setzt die Autosave-
|
||||
* Strategie aus dem Architektur-Dokument (Abschnitt 9) um:
|
||||
* - Checkbox-Änderungen: sofort persistiert
|
||||
* - Freitext-Änderungen: UI sofort aktualisiert, Persistierung über 300ms Debounce
|
||||
*
|
||||
* WICHTIG (siehe Architektur-Dokument Abschnitt 9.2): `onCleared()` wird bewusst NICHT als
|
||||
* Garantie für das Flushen ausstehender Änderungen verwendet, da Android dessen Aufruf bei
|
||||
* einem harten Prozessabbruch nicht garantiert. [flushPendingChanges] wird stattdessen vom
|
||||
* Screen aus einem `Lifecycle.Event.ON_STOP`-Observer aufgerufen – das deckt den häufigen Fall
|
||||
* (App gewechselt, Bildschirm gesperrt) ab, ist aber ausdrücklich keine Garantie für den
|
||||
* seltenen Fall eines sofortigen Systemkills.
|
||||
*/
|
||||
class DocumentationEditViewModel(
|
||||
private val documentationId: String,
|
||||
private val repository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DocumentationEditUiState())
|
||||
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val freeTextChanges = MutableSharedFlow<Pair<String, String>>(extraBufferCapacity = 64)
|
||||
|
||||
/** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */
|
||||
private val pendingFreeText = mutableMapOf<String, String>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loadDocumentation() }
|
||||
observeFreeTextChanges()
|
||||
}
|
||||
|
||||
private suspend fun loadDocumentation() {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
val documentation = repository.getFullDocumentation(documentationId)
|
||||
_uiState.update {
|
||||
if (documentation != null) {
|
||||
it.copy(documentation = documentation, isLoading = false)
|
||||
} else {
|
||||
it.copy(isLoading = false, error = "Dokumentation nicht gefunden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeFreeTextChanges() {
|
||||
viewModelScope.launch {
|
||||
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) ->
|
||||
persistFreeText(blockStateId, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistFreeText(blockStateId: String, text: String) {
|
||||
repository.updateFreeText(blockStateId, text)
|
||||
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
|
||||
if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId)
|
||||
}
|
||||
|
||||
fun onFreeTextChanged(blockStateId: String, text: String) {
|
||||
updateLocalFreeText(blockStateId, text)
|
||||
pendingFreeText[blockStateId] = text
|
||||
freeTextChanges.tryEmit(blockStateId to text)
|
||||
}
|
||||
|
||||
private fun updateLocalFreeText(blockStateId: String, text: String) {
|
||||
_uiState.update { state ->
|
||||
val documentation = state.documentation ?: return@update state
|
||||
val updatedBlocks = documentation.blocks.map { block ->
|
||||
if (block.blockStateId == blockStateId) block.copy(freeText = text) else block
|
||||
}
|
||||
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
||||
}
|
||||
}
|
||||
|
||||
fun onCheckedChanged(checkItemStateId: String, checked: Boolean) {
|
||||
// 1) UI sofort aktualisieren
|
||||
_uiState.update { state ->
|
||||
val documentation = state.documentation ?: return@update state
|
||||
val updatedBlocks = documentation.blocks.map { block ->
|
||||
val updatedItems = block.items.map { item ->
|
||||
if (item.checkItemStateId == checkItemStateId) item.copy(checked = checked) else item
|
||||
}
|
||||
block.copy(items = updatedItems)
|
||||
}
|
||||
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
||||
}
|
||||
// 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE)
|
||||
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
|
||||
}
|
||||
|
||||
fun onNextBlock() {
|
||||
_uiState.update { state ->
|
||||
val lastIndex = state.blockCount - 1
|
||||
if (lastIndex < 0) return@update state
|
||||
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
|
||||
}
|
||||
}
|
||||
|
||||
fun onPreviousBlock() {
|
||||
_uiState.update { state ->
|
||||
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
|
||||
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
|
||||
* hartem Prozessabbruch.
|
||||
*/
|
||||
fun flushPendingChanges() {
|
||||
if (pendingFreeText.isEmpty()) return
|
||||
val toFlush = pendingFreeText.toMap()
|
||||
pendingFreeText.clear()
|
||||
viewModelScope.launch {
|
||||
toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Debounce-Fenster für Freitext-Autosave. Siehe Architektur-Dokument Abschnitt 9.3:
|
||||
* bewusst 300ms statt 500ms, da bei dieser kleinen lokalen SQLite-Datenbank Robustheit
|
||||
* gegenüber reiner Schreibersparnis priorisiert werden darf. */
|
||||
private const val FREE_TEXT_DEBOUNCE_MS = 300L
|
||||
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
val savedStateHandle = createSavedStateHandle()
|
||||
val route = savedStateHandle.toRoute<Screen.DocumentationEdit>()
|
||||
DocumentationEditViewModel(
|
||||
documentationId = route.documentationId,
|
||||
repository = container.documentationRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package de.medapp.wunddoku.ui.documentation.list
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
import de.medapp.wunddoku.domain.model.DocumentationStatus
|
||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.ZoneId
|
||||
|
||||
private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
|
||||
/**
|
||||
* Startscreen der App (Aufgabenstellung Abschnitt 5): Liste aller Wunddokumentationen,
|
||||
* durchsuchbar, mit den Aktionen Bearbeiten/Fortsetzen/Löschen (mit Bestätigung).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentationListScreen(
|
||||
onOpenDocumentation: (documentationId: String) -> Unit,
|
||||
onNewDocumentation: () -> Unit,
|
||||
onOpenPatients: () -> Unit,
|
||||
viewModel: DocumentationListViewModel = viewModel(factory = DocumentationListViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var pendingDeleteId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.documentation_list_title)) },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenPatients) {
|
||||
Icon(Icons.Filled.People, contentDescription = stringResource(R.string.patient_list_title))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(onClick = onNewDocumentation) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text(stringResource(R.string.documentation_list_new))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChanged,
|
||||
label = { Text(stringResource(R.string.documentation_list_search_hint)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
|
||||
if (uiState.items.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.documentation_list_empty),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(uiState.items, key = { it.id }) { item ->
|
||||
DocumentationListItemCard(
|
||||
item = item,
|
||||
onOpen = { onOpenDocumentation(item.id) },
|
||||
onContinue = {
|
||||
viewModel.continueDocumentation(item.id) { newId -> onOpenDocumentation(newId) }
|
||||
},
|
||||
onDeleteRequested = { pendingDeleteId = item.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deleteId = pendingDeleteId
|
||||
if (deleteId != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDeleteId = null },
|
||||
title = { Text(stringResource(R.string.documentation_delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.documentation_delete_confirm_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.deleteDocumentation(deleteId)
|
||||
pendingDeleteId = null
|
||||
}) { Text(stringResource(R.string.action_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DocumentationListItemCard(
|
||||
item: DocumentationSummary,
|
||||
onOpen: () -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
onDeleteRequested: () -> Unit,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text(item.patientFullName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Erstellt: ${item.createdAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (item.updatedAt != item.createdAt) {
|
||||
Text(
|
||||
"Geändert: ${item.updatedAt.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (item.status == DocumentationStatus.COMPLETED) {
|
||||
Text("Abgeschlossen", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Text("⋮")
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_edit)) },
|
||||
onClick = { menuExpanded = false; onOpen() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_continue)) },
|
||||
onClick = { menuExpanded = false; onContinue() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_delete)) },
|
||||
onClick = { menuExpanded = false; onDeleteRequested() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package de.medapp.wunddoku.ui.documentation.list
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.model.DocumentationSummary
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DocumentationListUiState(
|
||||
val items: List<DocumentationSummary> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Kotlin-Hinweis zu `StateFlow`/`Flow`/`flatMapLatest`: [searchQuery] ist der Eingabetext, aus
|
||||
* dem wir per `flatMapLatest` jeweils den passenden Datenbank-`Flow` ableiten (entweder "alle"
|
||||
* oder "gefiltert nach Suchtext") – ändert sich der Suchtext erneut, während die vorherige
|
||||
* Datenbankabfrage noch läuft, bricht `flatMapLatest` diese automatisch ab und wechselt zur
|
||||
* neuen. Das verhindert veraltete Suchergebnisse ohne manuelles Abbrechen von Coroutines.
|
||||
*/
|
||||
class DocumentationListViewModel(
|
||||
private val repository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val items = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) repository.observeAll() else repository.search(query)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<DocumentationListUiState> = combine(items, searchQuery) { list, query ->
|
||||
DocumentationListUiState(items = list, searchQuery = query)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = DocumentationListUiState(),
|
||||
)
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
fun deleteDocumentation(documentationId: String) {
|
||||
viewModelScope.launch { repository.delete(documentationId) }
|
||||
}
|
||||
|
||||
fun continueDocumentation(documentationId: String, onCreated: (newDocumentationId: String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val newDocumentation = repository.continueDocumentation(documentationId)
|
||||
onCreated(newDocumentation.id)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
DocumentationListViewModel(container.documentationRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.medapp.wunddoku.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen
|
||||
import de.medapp.wunddoku.ui.documentation.list.DocumentationListScreen
|
||||
import de.medapp.wunddoku.ui.patients.form.PatientFormScreen
|
||||
import de.medapp.wunddoku.ui.patients.list.PatientListScreen
|
||||
|
||||
/**
|
||||
* Navigations-Graph gemäß Architektur-Dokument Abschnitt 8:
|
||||
*
|
||||
* DocumentationList -> PatientForm(null) -> DocumentationEdit(neue Id)
|
||||
* DocumentationList -> PatientList -> PatientForm(id) / DocumentationEdit (bearbeiten/fortsetzen)
|
||||
* DocumentationEdit -> zurück zu DocumentationList (nach Abschluss/PDF)
|
||||
*/
|
||||
@Composable
|
||||
fun WunddokuNavGraph() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = Screen.DocumentationList) {
|
||||
|
||||
composable<Screen.DocumentationList> {
|
||||
DocumentationListScreen(
|
||||
onOpenDocumentation = { id -> navController.navigate(Screen.DocumentationEdit(id)) },
|
||||
onNewDocumentation = {
|
||||
navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = true))
|
||||
},
|
||||
onOpenPatients = { navController.navigate(Screen.PatientList) },
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.PatientList> {
|
||||
PatientListScreen(
|
||||
onEditPatient = { id ->
|
||||
navController.navigate(Screen.PatientForm(patientId = id, startsNewDocumentation = false))
|
||||
},
|
||||
onNewPatient = {
|
||||
navController.navigate(Screen.PatientForm(patientId = null, startsNewDocumentation = false))
|
||||
},
|
||||
onOpenDocumentation = { documentationId -> navController.navigate(Screen.DocumentationEdit(documentationId)) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.PatientForm> {
|
||||
PatientFormScreen(
|
||||
onPatientSavedOnly = { navController.popBackStack() },
|
||||
onDocumentationCreated = { documentationId ->
|
||||
// Das Formular wird aus dem Back-Stack entfernt, damit "Zurück" aus der
|
||||
// Dokumentation nicht wieder im Formular landet.
|
||||
navController.navigate(Screen.DocumentationEdit(documentationId)) {
|
||||
popUpTo<Screen.DocumentationList> { inclusive = false }
|
||||
}
|
||||
},
|
||||
onCancel = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.DocumentationEdit> {
|
||||
DocumentationEditScreen(
|
||||
onFinished = {
|
||||
navController.navigate(Screen.DocumentationList) {
|
||||
popUpTo<Screen.DocumentationList> { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.medapp.wunddoku.ui.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Typsichere Navigations-Routen (aktueller Standard von Navigation Compose seit 2.8, statt
|
||||
* loser String-Routen mit manuellem Argument-Parsing). Tippfehler in Routen-Namen fallen damit
|
||||
* bereits beim Kompilieren auf statt erst zur Laufzeit.
|
||||
*
|
||||
* Kotlin-Hinweis: `sealed interface` fasst die möglichen Bildschirme zu einer geschlossenen,
|
||||
* dem Compiler bekannten Menge zusammen (siehe auch Erläuterung bei DocumentationStatus, wo
|
||||
* bewusst KEIN sealed interface verwendet wird, weil dort keine zustandsspezifischen Daten
|
||||
* nötig sind) – hier tragen die Varianten dagegen unterschiedliche, jeweils eigene Argumente
|
||||
* (z. B. `documentationId`), was `sealed interface` hier zur passenden Wahl macht.
|
||||
* `@Serializable` (kotlinx.serialization) ist die Voraussetzung dafür, dass Navigation Compose
|
||||
* eine Route-Instanz automatisch in einen Navigations-Zustand kodieren/dekodieren kann.
|
||||
*/
|
||||
sealed interface Screen {
|
||||
|
||||
@Serializable
|
||||
data object DocumentationList : Screen
|
||||
|
||||
@Serializable
|
||||
data object PatientList : Screen
|
||||
|
||||
/**
|
||||
* [startsNewDocumentation] unterscheidet zwei fachlich unterschiedliche Abläufe, die sich
|
||||
* dasselbe Formular teilen (Aufgabenstellung Abschnitt 7 Schritt 1 vs. Abschnitt 6
|
||||
* "Patient bearbeiten"):
|
||||
* - `true`: Formular ist Schritt 1 einer neuen Wunddokumentation ("Neu" auf der
|
||||
* Dokumentationsliste). Nach dem Speichern/Duplikat-Abgleich wird direkt eine neue,
|
||||
* leere Dokumentation angelegt und geöffnet.
|
||||
* - `false`: reines Bearbeiten der Patienten-Stammdaten (aus der Patientenliste). Nach dem
|
||||
* Speichern geht es zurück zur Patientenliste, ohne eine Dokumentation anzulegen.
|
||||
*/
|
||||
@Serializable
|
||||
data class PatientForm(val patientId: String? = null, val startsNewDocumentation: Boolean = false) : Screen
|
||||
|
||||
@Serializable
|
||||
data class DocumentationEdit(val documentationId: String) : Screen
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package de.medapp.wunddoku.ui.patients.form
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
|
||||
/**
|
||||
* Formular für Schritt 1 einer neuen Wunddokumentation (Aufgabenstellung Abschnitt 7) UND für
|
||||
* das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über
|
||||
* [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm].
|
||||
*
|
||||
* Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT)
|
||||
* erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle
|
||||
* Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PatientFormScreen(
|
||||
onPatientSavedOnly: () -> Unit,
|
||||
onDocumentationCreated: (documentationId: String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
viewModel: PatientFormViewModel = viewModel(factory = PatientFormViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(uiState.patientSaved) {
|
||||
if (uiState.patientSaved) onPatientSavedOnly()
|
||||
}
|
||||
LaunchedEffect(uiState.createdDocumentationId) {
|
||||
uiState.createdDocumentationId?.let { onDocumentationCreated(it) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (uiState.isEditing) R.string.patient_form_title_edit else R.string.patient_form_title_new
|
||||
)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (uiState.isLoading) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = uiState.firstName,
|
||||
onValueChange = viewModel::onFirstNameChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_first_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.lastName,
|
||||
onValueChange = viewModel::onLastNameChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_last_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.birthDateText,
|
||||
onValueChange = viewModel::onBirthDateTextChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_birth_date)) },
|
||||
placeholder = { Text("JJJJ-MM-TT") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.address,
|
||||
onValueChange = viewModel::onAddressChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_address)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.phone1,
|
||||
onValueChange = viewModel::onPhone1Changed,
|
||||
label = { Text(stringResource(R.string.patient_form_phone1)) },
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.phone2,
|
||||
onValueChange = viewModel::onPhone2Changed,
|
||||
label = { Text(stringResource(R.string.patient_form_phone2)) },
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.phone3,
|
||||
onValueChange = viewModel::onPhone3Changed,
|
||||
label = { Text(stringResource(R.string.patient_form_phone3)) },
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
uiState.validationError?.let { error ->
|
||||
Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.patient_form_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val duplicate = uiState.duplicateWarning
|
||||
if (duplicate != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissDuplicateWarning,
|
||||
title = { Text("Patient existiert bereits") },
|
||||
text = {
|
||||
Text(
|
||||
"Es existiert bereits ein Patient mit diesem Namen und Geburtsdatum " +
|
||||
"(${duplicate.fullName}, ${duplicate.birthDate}). Möchtest du diesen " +
|
||||
"vorhandenen Patienten verwenden, oder trotzdem einen neuen Datensatz anlegen?"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::useExistingPatient) {
|
||||
Text("Vorhandenen verwenden")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::createAnyway) {
|
||||
Text("Trotzdem neu anlegen")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package de.medapp.wunddoku.ui.patients.form
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.navigation.toRoute
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.model.Patient
|
||||
import de.medapp.wunddoku.ui.navigation.Screen
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
data class PatientFormUiState(
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = true,
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
/** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */
|
||||
val birthDateText: String = "",
|
||||
val address: String = "",
|
||||
val phone1: String = "",
|
||||
val phone2: String = "",
|
||||
val phone3: String = "",
|
||||
val validationError: String? = null,
|
||||
/** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */
|
||||
val duplicateWarning: Patient? = null,
|
||||
/** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */
|
||||
val patientSaved: Boolean = false,
|
||||
/** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */
|
||||
val createdDocumentationId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab
|
||||
* (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6).
|
||||
*/
|
||||
class PatientFormViewModel(
|
||||
private val patientId: String?,
|
||||
private val startsNewDocumentation: Boolean,
|
||||
private val repository: PatientRepository,
|
||||
private val documentationRepository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null))
|
||||
val uiState: StateFlow<PatientFormUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
if (patientId != null) {
|
||||
viewModelScope.launch {
|
||||
val patient = repository.getById(patientId)
|
||||
_uiState.update {
|
||||
if (patient != null) {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
firstName = patient.firstName,
|
||||
lastName = patient.lastName,
|
||||
birthDateText = patient.birthDate.toString(),
|
||||
address = patient.address,
|
||||
phone1 = patient.phone1,
|
||||
phone2 = patient.phone2.orEmpty(),
|
||||
phone3 = patient.phone3.orEmpty(),
|
||||
)
|
||||
} else {
|
||||
it.copy(isLoading = false, validationError = "Patient nicht gefunden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) }
|
||||
fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) }
|
||||
fun onBirthDateTextChanged(value: String) = _uiState.update { it.copy(birthDateText = value) }
|
||||
fun onAddressChanged(value: String) = _uiState.update { it.copy(address = value) }
|
||||
fun onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) }
|
||||
fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) }
|
||||
fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) }
|
||||
|
||||
fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
|
||||
|
||||
/** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */
|
||||
fun useExistingPatient() {
|
||||
val existing = _uiState.value.duplicateWarning ?: return
|
||||
_uiState.update { it.copy(duplicateWarning = null) }
|
||||
viewModelScope.launch { finish(existing.id) }
|
||||
}
|
||||
|
||||
/** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */
|
||||
fun createAnyway() {
|
||||
_uiState.update { it.copy(duplicateWarning = null) }
|
||||
save(skipDuplicateCheck = true)
|
||||
}
|
||||
|
||||
fun save(skipDuplicateCheck: Boolean = false) {
|
||||
val state = _uiState.value
|
||||
|
||||
val birthDate = try {
|
||||
LocalDate.parse(state.birthDateText.trim())
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") }
|
||||
return
|
||||
}
|
||||
if (state.firstName.isBlank() || state.lastName.isBlank()) {
|
||||
_uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") }
|
||||
return
|
||||
}
|
||||
if (state.address.isBlank() || state.phone1.isBlank()) {
|
||||
_uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
if (!state.isEditing && !skipDuplicateCheck) {
|
||||
val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate)
|
||||
if (existing != null) {
|
||||
_uiState.update { it.copy(duplicateWarning = existing, validationError = null) }
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val patient = Patient(
|
||||
id = patientId ?: repository.newId(),
|
||||
firstName = state.firstName.trim(),
|
||||
lastName = state.lastName.trim(),
|
||||
birthDate = birthDate,
|
||||
address = state.address.trim(),
|
||||
phone1 = state.phone1.trim(),
|
||||
phone2 = state.phone2.trim().ifBlank { null },
|
||||
phone3 = state.phone3.trim().ifBlank { null },
|
||||
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
val saved = repository.upsert(patient)
|
||||
_uiState.update { it.copy(validationError = null) }
|
||||
finish(saved.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
|
||||
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
|
||||
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder
|
||||
* nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
|
||||
*/
|
||||
private suspend fun finish(resolvedPatientId: String) {
|
||||
if (startsNewDocumentation) {
|
||||
val documentation = documentationRepository.createEmpty(resolvedPatientId)
|
||||
_uiState.update { it.copy(createdDocumentationId = documentation.id) }
|
||||
} else {
|
||||
_uiState.update { it.copy(patientSaved = true) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
// createSavedStateHandle() ist die von Google empfohlene Art, innerhalb eines
|
||||
// viewModelFactory-Initializers an das SavedStateHandle zu kommen; toRoute()
|
||||
// dekodiert daraus direkt die typsichere Navigations-Route (Screen.PatientForm),
|
||||
// statt Argumente manuell als lose Strings auszulesen.
|
||||
val savedStateHandle = createSavedStateHandle()
|
||||
val route = savedStateHandle.toRoute<Screen.PatientForm>()
|
||||
PatientFormViewModel(
|
||||
patientId = route.patientId,
|
||||
startsNewDocumentation = route.startsNewDocumentation,
|
||||
repository = container.patientRepository,
|
||||
documentationRepository = container.documentationRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package de.medapp.wunddoku.ui.patients.list
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
import de.medapp.wunddoku.domain.model.PatientListItem
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.ZoneId
|
||||
|
||||
private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
|
||||
/** Separate Patientenansicht (Aufgabenstellung Abschnitt 6). */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PatientListScreen(
|
||||
onEditPatient: (patientId: String) -> Unit,
|
||||
onNewPatient: () -> Unit,
|
||||
onOpenDocumentation: (documentationId: String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: PatientListViewModel = viewModel(factory = PatientListViewModel.factory()),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.patient_list_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(onClick = onNewPatient) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text(stringResource(R.string.patient_list_new))
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChanged,
|
||||
label = { Text(stringResource(R.string.patient_list_search_hint)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
|
||||
if (uiState.items.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.patient_list_empty),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(uiState.items, key = { it.id }) { item ->
|
||||
PatientListItemCard(
|
||||
item = item,
|
||||
onEdit = { onEditPatient(item.id) },
|
||||
onDeleteRequested = { viewModel.requestDelete(item.id) },
|
||||
onNewDocumentation = {
|
||||
viewModel.createDocumentation(item.id) { docId -> onOpenDocumentation(docId) }
|
||||
},
|
||||
onContinueDocumentation = {
|
||||
viewModel.continueDocumentation(item.id) { docId ->
|
||||
if (docId != null) {
|
||||
onOpenDocumentation(docId)
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
"Für diesen Patienten existiert noch keine Dokumentation zum Fortsetzen."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.deleteBlockedForPatientId != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissDeleteBlockedDialog,
|
||||
title = { Text(stringResource(R.string.patient_delete_blocked_title)) },
|
||||
text = { Text(stringResource(R.string.patient_delete_blocked_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::dismissDeleteBlockedDialog) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PatientListItemCard(
|
||||
item: PatientListItem,
|
||||
onEdit: () -> Unit,
|
||||
onDeleteRequested: () -> Unit,
|
||||
onNewDocumentation: () -> Unit,
|
||||
onContinueDocumentation: () -> Unit,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(onClick = onEdit, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text(item.fullName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Geburtsdatum: ${item.birthDate.format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(item.address, style = MaterialTheme.typography.bodySmall)
|
||||
val lastDoc = item.lastDocumentationAt
|
||||
if (lastDoc != null) {
|
||||
Text(
|
||||
"Letzte Dokumentation: ${lastDoc.atZone(ZoneId.systemDefault()).toLocalDate().format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Text("⋮")
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_edit)) },
|
||||
onClick = { menuExpanded = false; onEdit() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Neue Dokumentation") },
|
||||
onClick = { menuExpanded = false; onNewDocumentation() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_continue)) },
|
||||
onClick = { menuExpanded = false; onContinueDocumentation() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.documentation_action_delete)) },
|
||||
onClick = { menuExpanded = false; onDeleteRequested() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.medapp.wunddoku.ui.patients.list
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.data.repository.PatientRepository
|
||||
import de.medapp.wunddoku.di.wunddokuApplication
|
||||
import de.medapp.wunddoku.domain.model.PatientListItem
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PatientListUiState(
|
||||
val items: List<PatientListItem> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
/** != null, solange der Bestätigungsdialog "Löschen nicht möglich" angezeigt werden soll. */
|
||||
val deleteBlockedForPatientId: String? = null,
|
||||
)
|
||||
|
||||
class PatientListViewModel(
|
||||
private val repository: PatientRepository,
|
||||
private val documentationRepository: DocumentationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
private val deleteBlockedForPatientId = MutableStateFlow<String?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val items = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) repository.observeAll() else repository.search(query)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<PatientListUiState> =
|
||||
combine(items, searchQuery, deleteBlockedForPatientId) { list, query, blockedId ->
|
||||
PatientListUiState(items = list, searchQuery = query, deleteBlockedForPatientId = blockedId)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = PatientListUiState(),
|
||||
)
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
/**
|
||||
* Löschversuch (Aufgabenstellung Abschnitt 6/20): Prüft zuerst, ob noch Dokumentationen zu
|
||||
* diesem Patienten existieren, statt die Löschung blind zu versuchen und eine rohe
|
||||
* Datenbank-Exception (ON DELETE RESTRICT) abfangen zu müssen. So kann eine verständliche
|
||||
* Meldung angezeigt werden, statt eines technischen Fehlers.
|
||||
*/
|
||||
fun requestDelete(patientId: String) {
|
||||
viewModelScope.launch {
|
||||
if (repository.hasDocumentations(patientId)) {
|
||||
deleteBlockedForPatientId.update { patientId }
|
||||
} else {
|
||||
repository.delete(patientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissDeleteBlockedDialog() {
|
||||
deleteBlockedForPatientId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* "neue Wunddokumentation für Patient erstellen" (Aufgabenstellung Abschnitt 6): Der
|
||||
* Patient existiert bereits, es ist deshalb KEIN Stammdatenformular nötig – anders als beim
|
||||
* Weg über die Dokumentationsliste ("Neu"), wo der Patient ggf. erst angelegt wird.
|
||||
*/
|
||||
fun createDocumentation(patientId: String, onCreated: (documentationId: String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val documentation = documentationRepository.createEmpty(patientId)
|
||||
onCreated(documentation.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Behandlung/Dokumentation fortsetzen" (Aufgabenstellung Abschnitt 6). Liefert `null` an
|
||||
* [onCreated], falls der Patient noch keine Dokumentation besitzt – die UI zeigt in diesem
|
||||
* Fall einen verständlichen Hinweis statt eines technischen Fehlers.
|
||||
*/
|
||||
fun continueDocumentation(patientId: String, onCreated: (documentationId: String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val documentation = documentationRepository.continueLatestForPatient(patientId)
|
||||
onCreated(documentation?.id)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
PatientListViewModel(container.patientRepository, container.documentationRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.medapp.wunddoku.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val LightColors = lightColorScheme()
|
||||
private val DarkColors = darkColorScheme()
|
||||
|
||||
/**
|
||||
* Bewusst schlichtes Material-3-Theme ohne individuelle Markenfarben – für dieses Projekt gab
|
||||
* es keine gestalterische Vorgabe. Dynamic Color (aus dem Systemwallpaper abgeleitete Farben,
|
||||
* ab Android 12) wird verwendet, wo verfügbar, sonst ein neutrales Standard-Farbschema.
|
||||
*/
|
||||
@Composable
|
||||
fun WunddokuTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user