Beta 1.0 Release #11
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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. */
|
||||
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<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 {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user