Skip to content

Commit 2fc8e63

Browse files
committed
Update datepicker textfield to be state-based
To simplify date formatting visualization and synchronization
1 parent 344c423 commit 2fc8e63

File tree

3 files changed

+66
-227
lines changed

3 files changed

+66
-227
lines changed

datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DatePickerItem.kt

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@
1616

1717
package com.google.android.fhir.datacapture.views.compose
1818

19-
import androidx.compose.foundation.text.KeyboardActions
2019
import androidx.compose.foundation.text.KeyboardOptions
20+
import androidx.compose.foundation.text.input.InputTransformation
21+
import androidx.compose.foundation.text.input.OutputTransformation
22+
import androidx.compose.foundation.text.input.TextFieldLineLimits
23+
import androidx.compose.foundation.text.input.insert
24+
import androidx.compose.foundation.text.input.maxLength
25+
import androidx.compose.foundation.text.input.rememberTextFieldState
26+
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
27+
import androidx.compose.foundation.text.input.then
2128
import androidx.compose.material3.DatePicker
2229
import androidx.compose.material3.DatePickerDialog
2330
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -35,10 +42,9 @@ import androidx.compose.runtime.getValue
3542
import androidx.compose.runtime.mutableStateOf
3643
import androidx.compose.runtime.remember
3744
import androidx.compose.runtime.setValue
45+
import androidx.compose.runtime.snapshotFlow
3846
import androidx.compose.ui.Modifier
39-
import androidx.compose.ui.focus.FocusDirection
4047
import androidx.compose.ui.focus.onFocusChanged
41-
import androidx.compose.ui.platform.LocalFocusManager
4248
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
4349
import androidx.compose.ui.platform.testTag
4450
import androidx.compose.ui.res.painterResource
@@ -51,6 +57,7 @@ import com.google.android.fhir.datacapture.R
5157
import com.google.android.fhir.datacapture.extensions.format
5258
import com.google.android.fhir.datacapture.extensions.toLocalDate
5359
import java.time.LocalDate
60+
import kotlinx.coroutines.flow.collectLatest
5461

5562
@OptIn(ExperimentalMaterial3Api::class)
5663
@Composable
@@ -67,47 +74,65 @@ internal fun DatePickerItem(
6774
parseStringToLocalDate: (String, DateFormatPattern) -> LocalDate?,
6875
onDateInputEntry: (DateInput) -> Unit,
6976
) {
70-
val focusManager = LocalFocusManager.current
7177
val keyboardController = LocalSoftwareKeyboardController.current
7278
var dateInputState by remember(dateInput) { mutableStateOf(dateInput) }
73-
val dateInputDisplay by remember(dateInputState) { derivedStateOf { dateInputState.display } }
79+
val textFieldState = rememberTextFieldState(dateInputState.display)
80+
var isFocused by remember { mutableStateOf(false) }
81+
82+
val firstDelimiterIndex =
83+
remember(dateInputFormat) {
84+
dateInputFormat.patternWithDelimiters.indexOf(dateInputFormat.delimiter)
85+
}
86+
val secondDelimiterIndex =
87+
remember(dateInputFormat) {
88+
dateInputFormat.patternWithDelimiters.lastIndexOf(dateInputFormat.delimiter)
89+
}
90+
val dateFormatLength =
91+
remember(dateInputFormat) { dateInputFormat.patternWithoutDelimiters.length }
7492

7593
var showDatePickerModal by remember { mutableStateOf(false) }
7694

77-
LaunchedEffect(dateInputState) {
78-
if (dateInputState != dateInput) {
79-
onDateInputEntry(dateInputState)
95+
// Sync external dateInput changes to textFieldState
96+
LaunchedEffect(dateInput) {
97+
if (!isFocused && dateInput.display != textFieldState.text.toString()) {
98+
textFieldState.setTextAndPlaceCursorAtEnd(dateInput.display)
8099
}
81100
}
82101

83-
OutlinedTextField(
84-
value = dateInputDisplay,
85-
onValueChange = {
86-
if (
87-
it.length <= dateInputFormat.patternWithoutDelimiters.length &&
88-
it.all { char -> char.isDigit() }
89-
) {
102+
// Monitor textFieldState changes and update dateInputState
103+
LaunchedEffect(textFieldState) {
104+
snapshotFlow { textFieldState.text.toString() }
105+
.collectLatest {
90106
val trimmedText = it.trim()
91107
val localDate =
92-
if (
93-
trimmedText.isNotBlank() &&
94-
trimmedText.length == dateInputFormat.patternWithoutDelimiters.length
95-
) {
108+
if (trimmedText.isNotBlank() && trimmedText.length == dateFormatLength) {
96109
parseStringToLocalDate(trimmedText, dateInputFormat.patternWithoutDelimiters)
97110
} else {
98111
null
99112
}
100-
dateInputState = DateInput(it, localDate)
113+
val newDateInput = DateInput(trimmedText, localDate)
114+
if (dateInputState != newDateInput) {
115+
dateInputState = newDateInput
116+
onDateInputEntry(newDateInput)
117+
}
101118
}
102-
},
103-
singleLine = true,
119+
}
120+
121+
OutlinedTextField(
122+
state = textFieldState,
123+
lineLimits = TextFieldLineLimits.SingleLine,
104124
label = { Text(labelText) },
105125
modifier =
106126
modifier
107127
.testTag(DATE_TEXT_INPUT_FIELD)
108128
.onFocusChanged {
129+
isFocused = it.isFocused
109130
if (!it.isFocused) {
110131
keyboardController?.hide()
132+
// Sync external dateInput changes to textFieldState
133+
if (dateInput.display != textFieldState.text.toString()) {
134+
textFieldState.setTextAndPlaceCursorAtEnd(dateInput.display)
135+
}
111136
}
112137
}
113138
.semantics { if (isError) error(helperText ?: "") },
@@ -122,17 +147,25 @@ internal fun DatePickerItem(
122147
}
123148
},
124149
enabled = enabled,
150+
inputTransformation =
151+
InputTransformation.maxLength(dateFormatLength).then {
152+
if (asCharSequence().any { !Character.isDigit(it) }) revertAllChanges()
153+
},
125154
keyboardOptions =
126155
KeyboardOptions(
127156
autoCorrectEnabled = false,
128157
keyboardType = KeyboardType.Number,
129158
imeAction = ImeAction.Done,
130159
),
131-
keyboardActions =
132-
KeyboardActions(
133-
onNext = { focusManager.moveFocus(FocusDirection.Down) },
134-
),
135-
visualTransformation = DateVisualTransformation(dateInputFormat),
160+
outputTransformation =
161+
OutputTransformation {
162+
if (length >= firstDelimiterIndex) {
163+
insert(firstDelimiterIndex, dateInputFormat.delimiter.toString())
164+
}
165+
if (length >= secondDelimiterIndex) {
166+
insert(secondDelimiterIndex, dateInputFormat.delimiter.toString())
167+
}
168+
},
136169
)
137170

138171
if (selectableDates != null && showDatePickerModal) {
@@ -141,11 +174,8 @@ internal fun DatePickerItem(
141174
selectableDates,
142175
onDateSelected = { dateMillis ->
143176
dateMillis?.toLocalDate()?.let {
144-
dateInputState =
145-
DateInput(
146-
display = it.format(dateInputFormat.patternWithoutDelimiters),
147-
value = it,
148-
)
177+
val formattedDate = it.format(dateInputFormat.patternWithoutDelimiters)
178+
textFieldState.setTextAndPlaceCursorAtEnd(formattedDate)
149179
}
150180
},
151181
) {
@@ -197,4 +227,8 @@ typealias DateFormatPattern = String
197227

198228
data class DateInput(val display: String, val value: LocalDate?)
199229

230+
data class DateInputFormat(val patternWithDelimiters: String, val delimiter: Char) {
231+
val patternWithoutDelimiters: String = patternWithDelimiters.replace(delimiter.toString(), "")
232+
}
233+
200234
const val DATE_TEXT_INPUT_FIELD = "date_picker_text_field"

datacapture/src/main/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformation.kt

Lines changed: 0 additions & 78 deletions
This file was deleted.

datacapture/src/test/java/com/google/android/fhir/datacapture/views/compose/DateVisualTransformationTest.kt

Lines changed: 0 additions & 117 deletions
This file was deleted.

0 commit comments

Comments
 (0)