FUNC: Neue Wunddoku mit Patienten durchsuchen

This commit is contained in:
2026-08-22 11:10:59 +02:00
parent 34de5b5c5a
commit 25469e745a
2 changed files with 162 additions and 2 deletions
@@ -1,7 +1,9 @@
package de.medapp.wunddoku.ui.patients.form package de.medapp.wunddoku.ui.patients.form
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -10,10 +12,13 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -33,9 +38,14 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R 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.DATE_GROUP_SIZES
import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation
import de.medapp.wunddoku.ui.components.format.PHONE_GROUP_SIZES 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 * Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von
@@ -100,6 +110,10 @@ fun PatientFormScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (!uiState.isEditing) {
ExistingPatientSearchSection(uiState = uiState, viewModel = viewModel)
}
OutlinedTextField( OutlinedTextField(
value = uiState.firstName, value = uiState.firstName,
onValueChange = viewModel::onFirstNameChanged, 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,
)
}
}
@@ -10,6 +10,7 @@ import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.model.Patient 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.DATE_MAX_DIGITS
import de.medapp.wunddoku.ui.components.format.PHONE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.PHONE_MAX_DIGITS
import de.medapp.wunddoku.ui.components.format.POSTAL_CODE_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.germanDateDigitsToLocalDateOrNull
import de.medapp.wunddoku.ui.components.format.toGermanDateDigits import de.medapp.wunddoku.ui.components.format.toGermanDateDigits
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@@ -45,6 +49,16 @@ data class PatientFormUiState(
val validationError: String? = null, val validationError: String? = null,
/** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */ /** Vorhandener Patient mit identischen Stammdaten, sofern beim Speichern gefunden. */
val duplicateWarning: Patient? = null, val duplicateWarning: Patient? = null,
/** Suchfeld "bestehenden Patienten auswählen" - nur relevant, solange isEditing = false. */
val patientSearchQuery: String = "",
val patientSearchResults: List<PatientListItem> = 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. */ /** != null, wenn reines Bearbeiten (startsNewDocumentation = false) abgeschlossen ist -> zurück zur Liste. */
val patientSaved: Boolean = false, val patientSaved: Boolean = false,
/** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */ /** != null, sobald eine neue Dokumentation angelegt wurde -> Navigation zu DocumentationEdit. */
@@ -64,7 +78,12 @@ class PatientFormViewModel(
private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null)) private val _uiState = MutableStateFlow(PatientFormUiState(isEditing = patientId != null))
val uiState: StateFlow<PatientFormUiState> = _uiState.asStateFlow() val uiState: StateFlow<PatientFormUiState> = _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 { init {
observePatientSearch()
if (patientId != null) { if (patientId != null) {
viewModelScope.launch { viewModelScope.launch {
val patient = repository.getById(patientId) val patient = repository.getById(patientId)
@@ -114,6 +133,54 @@ class PatientFormViewModel(
fun onPhone3Changed(raw: String) = fun onPhone3Changed(raw: String) =
_uiState.update { it.copy(phone3 = filterDigits(raw, PHONE_MAX_DIGITS)) } _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) } fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
/** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */ /** Der Nutzer entscheidet sich im Duplikat-Dialog, den bereits vorhandenen Patienten zu verwenden. */
@@ -151,7 +218,8 @@ class PatientFormViewModel(
} }
viewModelScope.launch { 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) val existing = repository.findExisting(state.firstName.trim(), state.lastName.trim(), birthDate)
if (existing != null) { if (existing != null) {
_uiState.update { it.copy(duplicateWarning = existing, validationError = null) } _uiState.update { it.copy(duplicateWarning = existing, validationError = null) }
@@ -160,7 +228,7 @@ class PatientFormViewModel(
} }
val patient = Patient( val patient = Patient(
id = patientId ?: repository.newId(), id = patientId ?: state.selectedExistingPatientId ?: repository.newId(),
firstName = state.firstName.trim(), firstName = state.firstName.trim(),
lastName = state.lastName.trim(), lastName = state.lastName.trim(),
birthDate = birthDate, birthDate = birthDate,