Grundlegende Funktionalitäten (Formulare, Speichern, Durchsuchen, etc.) #3
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user