Formularformatierung
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package de.medapp.wunddoku.ui.components.format
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Zentrale, wiederverwendbare Formatierungs-/Transformationslösung für ziffernbasierte
|
||||
* Eingabefelder (Geburtsdatum, Telefonnummern). Bewusst als EINE generische Klasse statt
|
||||
* getrennter Lösungen pro Feldtyp, damit die Formatierungslogik nicht auf mehrere Screens
|
||||
* verteilt wird.
|
||||
*
|
||||
* Funktionsweise: Der zugrunde liegende Wert (`value` im TextField) bleibt immer eine reine
|
||||
* Ziffernfolge ohne Trennzeichen - das ist die Quelle der Wahrheit im ViewModel. Die
|
||||
* [VisualTransformation] fügt Trennzeichen ausschließlich für die DARSTELLUNG ein, ohne den
|
||||
* zugrunde liegenden Text zu verändern. Dadurch bleibt die Persistenzschicht vollständig
|
||||
* unabhängig von der UI-Darstellung: Repository/DAO sehen nur die rohe Ziffernfolge
|
||||
* (Telefonnummern) bzw. eine daraus geparste LocalDate (Geburtsdatum).
|
||||
*
|
||||
* [groupSizes] beschreibt die Länge jeder Zifferngruppe von links nach rechts (z. B. [2,2,4]
|
||||
* für TT.MM.JJJJ). Ziffern, die über die Summe aller Gruppen hinausgehen, werden ohne weitere
|
||||
* Gruppierung angehängt (z. B. [5] für Telefonnummern: nur nach der fünften Ziffer ein
|
||||
* Leerzeichen, danach durchgehend).
|
||||
*/
|
||||
class DigitGroupVisualTransformation(
|
||||
private val groupSizes: List<Int>,
|
||||
private val separator: Char,
|
||||
) : VisualTransformation {
|
||||
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val digits = text.text
|
||||
val formatted = StringBuilder()
|
||||
// separatorsBeforeDigit[i] = Anzahl der Trennzeichen, die in der formatierten Darstellung
|
||||
// VOR der i-ten Ziffer stehen - wird für die Cursor-Rückrechnung (OffsetMapping) benötigt.
|
||||
val separatorsBeforeDigit = IntArray(digits.length + 1)
|
||||
|
||||
var groupIndex = 0
|
||||
var posInGroup = 0
|
||||
for (i in digits.indices) {
|
||||
val currentGroupSize = groupSizes.getOrNull(groupIndex)
|
||||
if (currentGroupSize != null && posInGroup == currentGroupSize) {
|
||||
formatted.append(separator)
|
||||
groupIndex++
|
||||
posInGroup = 0
|
||||
}
|
||||
separatorsBeforeDigit[i] = formatted.count { it == separator }
|
||||
formatted.append(digits[i])
|
||||
posInGroup++
|
||||
}
|
||||
separatorsBeforeDigit[digits.length] = formatted.count { it == separator }
|
||||
|
||||
val offsetMapping = object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
val clamped = offset.coerceIn(0, digits.length)
|
||||
return clamped + separatorsBeforeDigit[clamped]
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
val clamped = offset.coerceIn(0, formatted.length)
|
||||
var digitCount = 0
|
||||
for (i in 0 until clamped) {
|
||||
if (formatted[i] != separator) digitCount++
|
||||
}
|
||||
return digitCount
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(AnnotatedString(formatted.toString()), offsetMapping)
|
||||
}
|
||||
}
|
||||
|
||||
/** Filtert eine Roheingabe auf reine Ziffern und begrenzt sie auf [maxDigits] Zeichen. */
|
||||
fun filterDigits(input: String, maxDigits: Int): String =
|
||||
input.filter { it.isDigit() }.take(maxDigits)
|
||||
|
||||
/** Gruppierung TT.MM.JJJJ, maximal 8 Ziffern. */
|
||||
val DATE_GROUP_SIZES = listOf(2, 2, 4)
|
||||
const val DATE_MAX_DIGITS = 8
|
||||
|
||||
/** Gruppierung: erste 5 Ziffern, dann ein Leerzeichen, danach ungruppiert. */
|
||||
val PHONE_GROUP_SIZES = listOf(5)
|
||||
const val PHONE_MAX_DIGITS = 15 // großzügige, defensive Obergrenze (E.164-Maximallänge)
|
||||
|
||||
const val POSTAL_CODE_MAX_DIGITS = 5 // deutsche PLZ
|
||||
|
||||
/**
|
||||
* Wandelt eine 8-stellige Ziffernfolge im Format TTMMJJJJ in ein [LocalDate] um, oder liefert
|
||||
* `null`, wenn die Länge nicht stimmt oder das Datum ungültig ist (z. B. "31022000" für den
|
||||
* nicht existierenden 31. Februar). `LocalDate.of(...)` wirft in diesem Fall eine
|
||||
* `DateTimeException`, die hier zu `null` statt einer stillschweigenden Fehlerkorrektur führt.
|
||||
*/
|
||||
fun germanDateDigitsToLocalDateOrNull(digits: String): LocalDate? {
|
||||
if (digits.length != DATE_MAX_DIGITS) return null
|
||||
return try {
|
||||
val day = digits.substring(0, 2).toInt()
|
||||
val month = digits.substring(2, 4).toInt()
|
||||
val year = digits.substring(4, 8).toInt()
|
||||
LocalDate.of(year, month, day)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Kehrfunktion zu [germanDateDigitsToLocalDateOrNull]: liefert die TTMMJJJJ-Ziffernfolge. */
|
||||
fun LocalDate.toGermanDateDigits(): String =
|
||||
"%02d%02d%04d".format(dayOfMonth, monthValue, year)
|
||||
+44
-11
@@ -111,7 +111,7 @@ fun DocumentationEditScreen(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
block = currentBlock,
|
||||
onCheckedChanged = viewModel::onCheckedChanged,
|
||||
onFreeTextChanged = { text -> viewModel.onFreeTextChanged(currentBlock.blockStateId, text) },
|
||||
onFreeTextChanged = viewModel::onFreeTextChanged,
|
||||
canGoBack = uiState.currentBlockIndex > 0,
|
||||
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
|
||||
onPrevious = viewModel::onPreviousBlock,
|
||||
@@ -128,7 +128,7 @@ private fun DocumentationBlockContent(
|
||||
modifier: Modifier = Modifier,
|
||||
block: DocumentationBlock,
|
||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||
onFreeTextChanged: (String) -> Unit,
|
||||
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||
canGoBack: Boolean,
|
||||
canGoNext: Boolean,
|
||||
onPrevious: () -> Unit,
|
||||
@@ -142,15 +142,17 @@ private fun DocumentationBlockContent(
|
||||
) {
|
||||
Text(block.title, style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
block.items.forEach { item -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = block.freeText,
|
||||
onValueChange = onFreeTextChanged,
|
||||
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
)
|
||||
block.items.forEach { item ->
|
||||
if (item.isFreeText) {
|
||||
FreeTextCheckItemRow(
|
||||
item = item,
|
||||
onCheckedChanged = onCheckedChanged,
|
||||
onFreeTextChanged = onFreeTextChanged,
|
||||
)
|
||||
} else {
|
||||
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
@@ -179,3 +181,34 @@ private fun CheckItemRow(
|
||||
Text(item.label)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Freitext-Option eines Blocks (Änderungswunsch: Freitext als weitere auswählbare Option
|
||||
* statt eigenes Feld am Block). Das Textfeld ist deaktiviert, solange die zugehörige Checkbox
|
||||
* nicht angehakt ist - so kann kein Freitext eingegeben werden, ohne dass die Option auch als
|
||||
* "bearbeitet" gilt (siehe BlockEditedPolicy: `items.any { it.checked }`).
|
||||
*/
|
||||
@Composable
|
||||
private fun FreeTextCheckItemRow(
|
||||
item: CheckItem,
|
||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
|
||||
)
|
||||
Text(item.label)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = item.freeTextValue,
|
||||
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) },
|
||||
enabled = item.checked,
|
||||
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 40.dp),
|
||||
minLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-13
@@ -51,7 +51,8 @@ class DocumentationEditViewModel(
|
||||
|
||||
private val freeTextChanges = MutableSharedFlow<Pair<String, String>>(extraBufferCapacity = 64)
|
||||
|
||||
/** Letzter noch nicht persistierter Freitext-Wert je BlockState-ID, für [flushPendingChanges]. */
|
||||
/** Letzter noch nicht persistierter Freitext-Wert je CheckItemState-ID (Freitext-Option
|
||||
* eines Blocks), für [flushPendingChanges]. */
|
||||
private val pendingFreeText = mutableMapOf<String, String>()
|
||||
|
||||
init {
|
||||
@@ -74,29 +75,37 @@ class DocumentationEditViewModel(
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeFreeTextChanges() {
|
||||
viewModelScope.launch {
|
||||
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (blockStateId, text) ->
|
||||
persistFreeText(blockStateId, text)
|
||||
freeTextChanges.debounce(FREE_TEXT_DEBOUNCE_MS).collect { (checkItemStateId, text) ->
|
||||
persistFreeText(checkItemStateId, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistFreeText(blockStateId: String, text: String) {
|
||||
repository.updateFreeText(blockStateId, text)
|
||||
private suspend fun persistFreeText(checkItemStateId: String, text: String) {
|
||||
repository.updateFreeText(checkItemStateId, text)
|
||||
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
|
||||
if (pendingFreeText[blockStateId] == text) pendingFreeText.remove(blockStateId)
|
||||
if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId)
|
||||
}
|
||||
|
||||
fun onFreeTextChanged(blockStateId: String, text: String) {
|
||||
updateLocalFreeText(blockStateId, text)
|
||||
pendingFreeText[blockStateId] = text
|
||||
freeTextChanges.tryEmit(blockStateId to text)
|
||||
/**
|
||||
* [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText =
|
||||
* true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine
|
||||
* Checkbox-Option kein eigenes Feld am Block mehr.
|
||||
*/
|
||||
fun onFreeTextChanged(checkItemStateId: String, text: String) {
|
||||
updateLocalFreeText(checkItemStateId, text)
|
||||
pendingFreeText[checkItemStateId] = text
|
||||
freeTextChanges.tryEmit(checkItemStateId to text)
|
||||
}
|
||||
|
||||
private fun updateLocalFreeText(blockStateId: String, text: String) {
|
||||
private fun updateLocalFreeText(checkItemStateId: String, text: String) {
|
||||
_uiState.update { state ->
|
||||
val documentation = state.documentation ?: return@update state
|
||||
val updatedBlocks = documentation.blocks.map { block ->
|
||||
if (block.blockStateId == blockStateId) block.copy(freeText = text) else block
|
||||
val updatedItems = block.items.map { item ->
|
||||
if (item.checkItemStateId == checkItemStateId) item.copy(freeTextValue = text) else item
|
||||
}
|
||||
block.copy(items = updatedItems)
|
||||
}
|
||||
state.copy(documentation = documentation.copy(blocks = updatedBlocks))
|
||||
}
|
||||
@@ -142,7 +151,7 @@ class DocumentationEditViewModel(
|
||||
val toFlush = pendingFreeText.toMap()
|
||||
pendingFreeText.clear()
|
||||
viewModelScope.launch {
|
||||
toFlush.forEach { (blockStateId, text) -> repository.updateFreeText(blockStateId, text) }
|
||||
toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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
|
||||
@@ -15,6 +16,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -31,15 +33,18 @@ 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.ui.components.format.DATE_GROUP_SIZES
|
||||
import de.medapp.wunddoku.ui.components.format.DigitGroupVisualTransformation
|
||||
import de.medapp.wunddoku.ui.components.format.PHONE_GROUP_SIZES
|
||||
|
||||
/**
|
||||
* Formular für Schritt 1 einer neuen Wunddokumentation (Aufgabenstellung Abschnitt 7) UND für
|
||||
* das reine Bearbeiten von Patienten-Stammdaten (Abschnitt 6) – unterschieden über
|
||||
* [PatientFormViewModel.startsNewDocumentation], s. Kommentar dort und bei [de.medapp.wunddoku.ui.navigation.Screen.PatientForm].
|
||||
* Formular für Schritt 1 einer neuen Wunddokumentation UND für das reine Bearbeiten von
|
||||
* Patienten-Stammdaten – unterschieden über [PatientFormViewModel.startsNewDocumentation].
|
||||
*
|
||||
* Hinweis: Das Geburtsdatum wird in Phase 2 noch als einfaches Textfeld (Format JJJJ-MM-TT)
|
||||
* erfasst. Ein Material-3-DatePicker-Dialog für bessere Eingabe-UX ist eine sinnvolle
|
||||
* Verbesserung für eine spätere Phase, aber kein Blocker für das Grundgerüst.
|
||||
* Geburtsdatum und Telefonnummern nutzen dieselbe wiederverwendbare
|
||||
* [DigitGroupVisualTransformation] (ui/components/format/FormattedInput.kt): Der zugrunde
|
||||
* liegende Zustand im ViewModel bleibt jeweils eine reine Ziffernfolge, die Trennzeichen
|
||||
* (Punkte bzw. Leerzeichen) werden ausschließlich für die Darstellung ergänzt.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -108,42 +113,62 @@ fun PatientFormScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.birthDateText,
|
||||
onValueChange = viewModel::onBirthDateTextChanged,
|
||||
value = uiState.birthDateDigits,
|
||||
onValueChange = viewModel::onBirthDateDigitsChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_birth_date)) },
|
||||
placeholder = { Text("JJJJ-MM-TT") },
|
||||
placeholder = { Text("TT.MM.JJJJ") },
|
||||
visualTransformation = DigitGroupVisualTransformation(DATE_GROUP_SIZES, '.'),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.street,
|
||||
onValueChange = viewModel::onStreetChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_street)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.address,
|
||||
onValueChange = viewModel::onAddressChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_address)) },
|
||||
value = uiState.postalCode,
|
||||
onValueChange = viewModel::onPostalCodeChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_postal_code)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.city,
|
||||
onValueChange = viewModel::onCityChanged,
|
||||
label = { Text(stringResource(R.string.patient_form_city)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.phone1,
|
||||
onValueChange = viewModel::onPhone1Changed,
|
||||
label = { Text(stringResource(R.string.patient_form_phone1)) },
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.phone2,
|
||||
onValueChange = viewModel::onPhone2Changed,
|
||||
label = { Text(stringResource(R.string.patient_form_phone2)) },
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.phone3,
|
||||
onValueChange = viewModel::onPhone3Changed,
|
||||
label = { Text(stringResource(R.string.patient_form_phone3)) },
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
visualTransformation = DigitGroupVisualTransformation(PHONE_GROUP_SIZES, ' '),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
uiState.validationError?.let { error ->
|
||||
Text(error, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
|
||||
Text(error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Button(onClick = { viewModel.save() }, modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -10,6 +10,12 @@ 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.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
|
||||
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.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -17,16 +23,22 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
data class PatientFormUiState(
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = true,
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
/** Roh-Text des Eingabefelds; Format JJJJ-MM-TT. Parsen erfolgt beim Speichern. */
|
||||
val birthDateText: String = "",
|
||||
val address: String = "",
|
||||
/** Rohe Ziffernfolge (max. 8 Zeichen, TTMMJJJJ) - die Formatierung TT.MM.JJJJ erfolgt
|
||||
* ausschließlich über eine VisualTransformation in der Compose-UI, s.
|
||||
* ui/components/format/FormattedInput.kt. */
|
||||
val birthDateDigits: String = "",
|
||||
val street: String = "",
|
||||
/** Rohe Ziffernfolge, max. 5 Zeichen (deutsche PLZ). */
|
||||
val postalCode: String = "",
|
||||
val city: String = "",
|
||||
/** Rohe Ziffernfolgen (führende Nullen bleiben erhalten, da als String geführt) - die
|
||||
* Leerzeichen-Formatierung erfolgt ausschließlich über eine VisualTransformation in der UI. */
|
||||
val phone1: String = "",
|
||||
val phone2: String = "",
|
||||
val phone3: String = "",
|
||||
@@ -40,8 +52,7 @@ data class PatientFormUiState(
|
||||
)
|
||||
|
||||
/**
|
||||
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab
|
||||
* (Aufgabenstellung Abschnitt 7 Schritt 1, Abschnitt 6).
|
||||
* Deckt sowohl "neuer Patient" (patientId == null) als auch "Patient bearbeiten" ab.
|
||||
*/
|
||||
class PatientFormViewModel(
|
||||
private val patientId: String?,
|
||||
@@ -63,8 +74,10 @@ class PatientFormViewModel(
|
||||
isLoading = false,
|
||||
firstName = patient.firstName,
|
||||
lastName = patient.lastName,
|
||||
birthDateText = patient.birthDate.toString(),
|
||||
address = patient.address,
|
||||
birthDateDigits = patient.birthDate.toGermanDateDigits(),
|
||||
street = patient.street,
|
||||
postalCode = patient.postalCode,
|
||||
city = patient.city,
|
||||
phone1 = patient.phone1,
|
||||
phone2 = patient.phone2.orEmpty(),
|
||||
phone3 = patient.phone3.orEmpty(),
|
||||
@@ -81,11 +94,25 @@ class PatientFormViewModel(
|
||||
|
||||
fun onFirstNameChanged(value: String) = _uiState.update { it.copy(firstName = value) }
|
||||
fun onLastNameChanged(value: String) = _uiState.update { it.copy(lastName = value) }
|
||||
fun onBirthDateTextChanged(value: String) = _uiState.update { it.copy(birthDateText = value) }
|
||||
fun onAddressChanged(value: String) = _uiState.update { it.copy(address = value) }
|
||||
fun onPhone1Changed(value: String) = _uiState.update { it.copy(phone1 = value) }
|
||||
fun onPhone2Changed(value: String) = _uiState.update { it.copy(phone2 = value) }
|
||||
fun onPhone3Changed(value: String) = _uiState.update { it.copy(phone3 = value) }
|
||||
|
||||
fun onBirthDateDigitsChanged(raw: String) =
|
||||
_uiState.update { it.copy(birthDateDigits = filterDigits(raw, DATE_MAX_DIGITS)) }
|
||||
|
||||
fun onStreetChanged(value: String) = _uiState.update { it.copy(street = value) }
|
||||
|
||||
fun onPostalCodeChanged(raw: String) =
|
||||
_uiState.update { it.copy(postalCode = filterDigits(raw, POSTAL_CODE_MAX_DIGITS)) }
|
||||
|
||||
fun onCityChanged(value: String) = _uiState.update { it.copy(city = value) }
|
||||
|
||||
fun onPhone1Changed(raw: String) =
|
||||
_uiState.update { it.copy(phone1 = filterDigits(raw, PHONE_MAX_DIGITS)) }
|
||||
|
||||
fun onPhone2Changed(raw: String) =
|
||||
_uiState.update { it.copy(phone2 = filterDigits(raw, PHONE_MAX_DIGITS)) }
|
||||
|
||||
fun onPhone3Changed(raw: String) =
|
||||
_uiState.update { it.copy(phone3 = filterDigits(raw, PHONE_MAX_DIGITS)) }
|
||||
|
||||
fun dismissDuplicateWarning() = _uiState.update { it.copy(duplicateWarning = null) }
|
||||
|
||||
@@ -105,18 +132,21 @@ class PatientFormViewModel(
|
||||
fun save(skipDuplicateCheck: Boolean = false) {
|
||||
val state = _uiState.value
|
||||
|
||||
val birthDate = try {
|
||||
LocalDate.parse(state.birthDateText.trim())
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum im Format JJJJ-MM-TT eingeben.") }
|
||||
val birthDate = germanDateDigitsToLocalDateOrNull(state.birthDateDigits)
|
||||
if (birthDate == null) {
|
||||
_uiState.update { it.copy(validationError = "Bitte ein gültiges Geburtsdatum eingeben (TT.MM.JJJJ).") }
|
||||
return
|
||||
}
|
||||
if (state.firstName.isBlank() || state.lastName.isBlank()) {
|
||||
_uiState.update { it.copy(validationError = "Vor- und Nachname dürfen nicht leer sein.") }
|
||||
return
|
||||
}
|
||||
if (state.address.isBlank() || state.phone1.isBlank()) {
|
||||
_uiState.update { it.copy(validationError = "Adresse und Telefonnummer 1 dürfen nicht leer sein.") }
|
||||
if (state.street.isBlank() || state.postalCode.isBlank() || state.city.isBlank()) {
|
||||
_uiState.update { it.copy(validationError = "Straße, PLZ und Stadt dürfen nicht leer sein.") }
|
||||
return
|
||||
}
|
||||
if (state.phone1.isBlank()) {
|
||||
_uiState.update { it.copy(validationError = "Telefonnummer 1 darf nicht leer sein.") }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,10 +164,12 @@ class PatientFormViewModel(
|
||||
firstName = state.firstName.trim(),
|
||||
lastName = state.lastName.trim(),
|
||||
birthDate = birthDate,
|
||||
address = state.address.trim(),
|
||||
phone1 = state.phone1.trim(),
|
||||
phone2 = state.phone2.trim().ifBlank { null },
|
||||
phone3 = state.phone3.trim().ifBlank { null },
|
||||
street = state.street.trim(),
|
||||
postalCode = state.postalCode.trim(),
|
||||
city = state.city.trim(),
|
||||
phone1 = state.phone1,
|
||||
phone2 = state.phone2.ifBlank { null },
|
||||
phone3 = state.phone3.ifBlank { null },
|
||||
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
@@ -150,8 +182,8 @@ class PatientFormViewModel(
|
||||
/**
|
||||
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
|
||||
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
|
||||
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder
|
||||
* nur signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
|
||||
* neue, leere Dokumentation anlegen und deren ID für die Navigation bereitstellen, oder nur
|
||||
* signalisieren, dass gespeichert wurde (zurück zur Patientenliste).
|
||||
*/
|
||||
private suspend fun finish(resolvedPatientId: String) {
|
||||
if (startsNewDocumentation) {
|
||||
@@ -166,10 +198,6 @@ class PatientFormViewModel(
|
||||
fun factory() = viewModelFactory {
|
||||
initializer {
|
||||
val container = wunddokuApplication().container
|
||||
// createSavedStateHandle() ist die von Google empfohlene Art, innerhalb eines
|
||||
// viewModelFactory-Initializers an das SavedStateHandle zu kommen; toRoute()
|
||||
// dekodiert daraus direkt die typsichere Navigations-Route (Screen.PatientForm),
|
||||
// statt Argumente manuell als lose Strings auszulesen.
|
||||
val savedStateHandle = createSavedStateHandle()
|
||||
val route = savedStateHandle.toRoute<Screen.PatientForm>()
|
||||
PatientFormViewModel(
|
||||
|
||||
@@ -163,7 +163,7 @@ private fun PatientListItemCard(
|
||||
"Geburtsdatum: ${item.birthDate.format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(item.address, style = MaterialTheme.typography.bodySmall)
|
||||
Text(item.displayAddress, style = MaterialTheme.typography.bodySmall)
|
||||
val lastDoc = item.lastDocumentationAt
|
||||
if (lastDoc != null) {
|
||||
Text(
|
||||
|
||||
Reference in New Issue
Block a user