Beta 1.0 Release #11

Merged
niklas.ortmann merged 24 commits from dev into main 2026-10-03 00:37:20 +02:00
2 changed files with 162 additions and 2 deletions
Showing only changes of commit 25469e745a - Show all commits
@@ -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,