From 25469e745a958afc1c4fe59b3a95858631dd5b5c Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Sat, 22 Aug 2026 11:10:59 +0200 Subject: [PATCH] FUNC: Neue Wunddoku mit Patienten durchsuchen --- .../ui/patients/form/PatientFormScreen.kt | 92 +++++++++++++++++++ .../ui/patients/form/PatientFormViewModel.kt | 72 ++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt index 9ef8370..69f3d74 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormScreen.kt @@ -1,7 +1,9 @@ package de.medapp.wunddoku.ui.patients.form +import androidx.compose.foundation.clickable 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 @@ -10,10 +12,13 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -33,9 +38,14 @@ 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 de.medapp.wunddoku.ui.components.format.DATE_GROUP_SIZES import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation import de.medapp.wunddoku.ui.components.format.PHONE_GROUP_SIZES +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) /** * Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von @@ -100,6 +110,10 @@ fun PatientFormScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (!uiState.isEditing) { + ExistingPatientSearchSection(uiState = uiState, viewModel = viewModel) + } + OutlinedTextField( value = uiState.firstName, onValueChange = viewModel::onFirstNameChanged, @@ -202,3 +216,81 @@ fun PatientFormScreen( ) } } + +/** + * "Bestehenden Patienten auswählen" - Suchfeld über der Stammdateneingabe (nur beim Anlegen + * eines neuen Patienten sichtbar, nicht beim Bearbeiten eines bereits feststehenden). Wählt der + * Nutzer einen Treffer aus, befüllt [PatientFormViewModel.onExistingPatientSelected] das + * restliche Formular automatisch; die Auswahl selbst wird als Chip mit "Aufheben"-Möglichkeit + * dargestellt, solange sie aktiv ist. + */ +@Composable +private fun ExistingPatientSearchSection( + uiState: PatientFormUiState, + viewModel: PatientFormViewModel, +) { + val selectedId = uiState.selectedExistingPatientId + if (selectedId != null) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text("Bestehender Patient ausgewählt", style = MaterialTheme.typography.labelMedium) + Text("${uiState.firstName} ${uiState.lastName}", style = MaterialTheme.typography.bodyLarge) + } + IconButton(onClick = viewModel::clearSelectedExistingPatient) { + Icon(Icons.Filled.Close, contentDescription = "Auswahl aufheben") + } + } + } + return + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + value = uiState.patientSearchQuery, + onValueChange = viewModel::onPatientSearchQueryChanged, + label = { Text("Bestehenden Patienten auswählen") }, + placeholder = { Text("Name oder Geburtsdatum suchen") }, + modifier = Modifier.fillMaxWidth(), + ) + + if (uiState.patientSearchQuery.isNotBlank()) { + if (uiState.patientSearchResults.isEmpty()) { + Text( + "Keine passenden Patienten gefunden.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } else { + Card(modifier = Modifier.fillMaxWidth()) { + Column { + uiState.patientSearchResults.forEachIndexed { index, item -> + if (index > 0) HorizontalDivider() + PatientSearchResultRow(item = item, onClick = { viewModel.onExistingPatientSelected(item.id) }) + } + } + } + } + } + } +} + +@Composable +private fun PatientSearchResultRow(item: PatientListItem, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(12.dp), + ) { + Text(item.fullName, style = MaterialTheme.typography.bodyLarge) + Text( + "${item.birthDate.format(dateFormatter)} · ${item.displayAddress}", + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt index fc15f18..d8e3d4f 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt @@ -10,6 +10,7 @@ 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.domain.model.PatientListItem import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.PHONE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.POSTAL_CODE_MAX_DIGITS @@ -17,9 +18,12 @@ import de.medapp.wunddoku.ui.components.format.filterDigits import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull import de.medapp.wunddoku.ui.components.format.toGermanDateDigits import de.medapp.wunddoku.ui.navigation.Screen +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.Instant @@ -45,6 +49,16 @@ data class PatientFormUiState( val validationError: String? = null, /** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */ val duplicateWarning: Patient? = null, + /** Suchfeld "bestehenden Patienten auswählen" - nur relevant, solange isEditing = false. */ + val patientSearchQuery: String = "", + val patientSearchResults: List = emptyList(), + /** + * != null, sobald der Nutzer über die Suche einen bestehenden Patienten ausgewählt hat. + * Das Formular ist dann mit dessen Daten vorbefüllt; Speichern aktualisiert diesen + * Patienten, statt einen neuen anzulegen, und überspringt die separate + * Duplikat-Erkennung (der Nutzer hat den Patienten bereits bewusst gewählt). + */ + val selectedExistingPatientId: String? = 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. */ @@ -64,7 +78,12 @@ class PatientFormViewModel( private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null)) val uiState: StateFlow = _uiState.asStateFlow() + /** Getrennt vom UiState-Textfeld geführt, damit flatMapLatest die Suche sauber debouncen/ + * abbrechen kann, ohne den restlichen UiState anzufassen. */ + private val patientSearchQuery = MutableStateFlow("") + init { + observePatientSearch() if (patientId != null) { viewModelScope.launch { val patient = repository.getById(patientId) @@ -114,6 +133,54 @@ class PatientFormViewModel( fun onPhone3Changed(raw: String) = _uiState.update { it.copy(phone3 = filterDigits(raw, PHONE_MAX_DIGITS)) } + @OptIn(ExperimentalCoroutinesApi::class) + private fun observePatientSearch() { + viewModelScope.launch { + patientSearchQuery + .flatMapLatest { query -> if (query.isBlank()) flowOf(emptyList()) else repository.search(query) } + .collect { results -> _uiState.update { it.copy(patientSearchResults = results) } } + } + } + + fun onPatientSearchQueryChanged(query: String) { + patientSearchQuery.value = query + _uiState.update { it.copy(patientSearchQuery = query) } + } + + /** + * Der Nutzer wählt im Suchfeld "bestehenden Patienten auswählen" einen Treffer aus - das + * restliche Formular wird automatisch mit dessen aktuellen Stammdaten befüllt. + */ + fun onExistingPatientSelected(patientId: String) { + viewModelScope.launch { + val patient = repository.getById(patientId) ?: return@launch + _uiState.update { + it.copy( + selectedExistingPatientId = patient.id, + firstName = patient.firstName, + lastName = patient.lastName, + birthDateDigits = patient.birthDate.toGermanDateDigits(), + street = patient.street, + postalCode = patient.postalCode, + city = patient.city, + phone1 = patient.phone1, + phone2 = patient.phone2.orEmpty(), + phone3 = patient.phone3.orEmpty(), + patientSearchQuery = "", + patientSearchResults = emptyList(), + validationError = null, + ) + } + patientSearchQuery.value = "" + } + } + + /** Hebt eine per Suche getroffene Auswahl wieder auf - die Formularfelder bleiben editierbar + * erhalten (Speichern legt dann einen NEUEN Patienten mit den aktuell eingetragenen Werten an). */ + fun clearSelectedExistingPatient() { + _uiState.update { it.copy(selectedExistingPatientId = null) } + } + fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) } /** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */ @@ -151,7 +218,8 @@ class PatientFormViewModel( } viewModelScope.launch { - if (!state.isEditing && !skipDuplicateCheck) { + val skipBecauseExplicitlySelected = state.selectedExistingPatientId != null + if (!state.isEditing && !skipDuplicateCheck && !skipBecauseExplicitlySelected) { val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate) if (existing != null) { _uiState.update { it.copy(duplicateWarning = existing, validationError = null) } @@ -160,7 +228,7 @@ class PatientFormViewModel( } val patient = Patient( - id = patientId ?: repository.newId(), + id = patientId ?: state.selectedExistingPatientId ?: repository.newId(), firstName = state.firstName.trim(), lastName = state.lastName.trim(), birthDate = birthDate,