Skip to content

Commit 9eb5df4

Browse files
committed
Migrate Timepicker (wip)
1 parent b63fa79 commit 9eb5df4

File tree

3 files changed

+210
-49
lines changed

3 files changed

+210
-49
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.android.fhir.datacapture.views.compose
18+
19+
import androidx.compose.foundation.clickable
20+
import androidx.compose.foundation.text.KeyboardActions
21+
import androidx.compose.foundation.text.KeyboardOptions
22+
import androidx.compose.material3.Icon
23+
import androidx.compose.material3.IconButton
24+
import androidx.compose.material3.OutlinedTextField
25+
import androidx.compose.material3.Text
26+
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.getValue
28+
import androidx.compose.runtime.mutableStateOf
29+
import androidx.compose.runtime.remember
30+
import androidx.compose.runtime.setValue
31+
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.focus.FocusDirection
33+
import androidx.compose.ui.focus.onFocusChanged
34+
import androidx.compose.ui.platform.LocalFocusManager
35+
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36+
import androidx.compose.ui.platform.testTag
37+
import androidx.compose.ui.res.painterResource
38+
import androidx.compose.ui.res.stringResource
39+
import androidx.compose.ui.semantics.error
40+
import androidx.compose.ui.semantics.semantics
41+
import androidx.compose.ui.text.input.ImeAction
42+
import androidx.compose.ui.text.input.KeyboardType
43+
import androidx.compose.ui.tooling.preview.Preview
44+
import com.google.android.fhir.datacapture.R
45+
import java.time.LocalTime
46+
47+
@Composable
48+
internal fun TimePickerItem(
49+
modifier: Modifier = Modifier,
50+
selectedTime: String?,
51+
enabled: Boolean,
52+
hint: String,
53+
supportingHelperText: String?,
54+
isError: Boolean,
55+
onTimeChanged: (LocalTime) -> Unit,
56+
) {
57+
val focusManager = LocalFocusManager.current
58+
val keyboardController = LocalSoftwareKeyboardController.current
59+
var selectedTimeText by remember(selectedTime) { mutableStateOf(selectedTime ?: "") }
60+
var showTimePickerModal by remember { mutableStateOf(false) }
61+
62+
OutlinedTextField(
63+
value = selectedTimeText,
64+
onValueChange = {},
65+
singleLine = true,
66+
label = { Text(hint) },
67+
modifier =
68+
modifier
69+
.testTag(TIME_PICKER_INPUT_FIELD)
70+
.onFocusChanged {
71+
if (!it.isFocused) {
72+
keyboardController?.hide()
73+
}
74+
}
75+
.clickable(
76+
onClick = {
77+
// todo: show with keyboard input
78+
showTimePickerModal = true
79+
},
80+
)
81+
.semantics {
82+
if (isError && !supportingHelperText.isNullOrBlank()) error(supportingHelperText)
83+
},
84+
supportingText = { supportingHelperText?.let { Text(it) } },
85+
isError = isError,
86+
trailingIcon = {
87+
IconButton(
88+
onClick = {
89+
// todo: show with time picker input
90+
showTimePickerModal = true
91+
},
92+
enabled = enabled,
93+
) {
94+
Icon(
95+
painterResource(R.drawable.gm_schedule_24),
96+
contentDescription = stringResource(R.string.select_time),
97+
)
98+
}
99+
},
100+
readOnly = true,
101+
enabled = enabled,
102+
keyboardOptions =
103+
KeyboardOptions(
104+
autoCorrectEnabled = false,
105+
keyboardType = KeyboardType.Number,
106+
imeAction = ImeAction.Done,
107+
),
108+
keyboardActions =
109+
KeyboardActions(
110+
onNext = { focusManager.moveFocus(FocusDirection.Down) },
111+
),
112+
)
113+
}
114+
115+
@Composable
116+
@Preview
117+
fun PreviewTimePickerItem() {
118+
TimePickerItem(Modifier, null, true, stringResource(R.string.time), null, false) {}
119+
}
120+
121+
const val TIME_PICKER_INPUT_FIELD = "time_picker_text_field"

datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ internal object DatePickerViewHolderFactory : QuestionnaireItemComposeViewHolder
8888
)
8989
}
9090
val questionnaireItemAnswerLocalDate =
91-
questionnaireViewItem.answers.singleOrNull()?.valueDateType?.localDate
91+
remember(questionnaireViewItem.answers) {
92+
questionnaireViewItem.answers.singleOrNull()?.valueDateType?.localDate
93+
}
9294
val questionnaireItemAnswerDateInMillis =
9395
remember(questionnaireItemAnswerLocalDate) {
9496
questionnaireItemAnswerLocalDate?.atStartOfDay(ZONE_ID_UTC)?.toInstant()?.toEpochMilli()

datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/TimePickerViewHolderFactory.kt

Lines changed: 86 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,35 @@
1616

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

19-
import android.annotation.SuppressLint
2019
import android.content.Context
2120
import android.text.InputType
2221
import android.text.format.DateFormat
2322
import android.view.View
2423
import androidx.appcompat.app.AppCompatActivity
24+
import androidx.compose.foundation.layout.Column
25+
import androidx.compose.foundation.layout.fillMaxWidth
26+
import androidx.compose.foundation.layout.padding
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.mutableStateOf
30+
import androidx.compose.runtime.remember
31+
import androidx.compose.runtime.rememberCoroutineScope
32+
import androidx.compose.runtime.setValue
33+
import androidx.compose.ui.Modifier
34+
import androidx.compose.ui.platform.LocalContext
35+
import androidx.compose.ui.res.dimensionResource
36+
import androidx.compose.ui.res.stringResource
2537
import androidx.lifecycle.lifecycleScope
2638
import com.google.android.fhir.datacapture.R
2739
import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText
40+
import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage
41+
import com.google.android.fhir.datacapture.extensions.itemMedia
2842
import com.google.android.fhir.datacapture.extensions.toLocalizedString
2943
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
30-
import com.google.android.fhir.datacapture.views.HeaderView
3144
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
45+
import com.google.android.fhir.datacapture.views.compose.Header
46+
import com.google.android.fhir.datacapture.views.compose.MediaItem
47+
import com.google.android.fhir.datacapture.views.compose.TimePickerItem
3248
import com.google.android.material.textfield.TextInputEditText
3349
import com.google.android.material.textfield.TextInputLayout
3450
import com.google.android.material.timepicker.MaterialTimePicker
@@ -37,25 +53,76 @@ import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_KEYB
3753
import com.google.android.material.timepicker.TimeFormat
3854
import java.time.LocalTime
3955
import java.time.format.DateTimeFormatter
56+
import kotlinx.coroutines.Dispatchers
4057
import kotlinx.coroutines.launch
4158
import org.hl7.fhir.r4.model.QuestionnaireResponse
4259
import org.hl7.fhir.r4.model.TimeType
4360

44-
object TimePickerViewHolderFactory :
45-
QuestionnaireItemAndroidViewHolderFactory(R.layout.time_picker_view) {
61+
object TimePickerViewHolderFactory : QuestionnaireItemComposeViewHolderFactory {
4662

4763
override fun getQuestionnaireItemViewHolderDelegate() =
48-
object : QuestionnaireItemAndroidViewHolderDelegate {
64+
object : QuestionnaireItemComposeViewHolderDelegate {
4965
private val TAG = "time-picker"
5066
private lateinit var context: AppCompatActivity
51-
private lateinit var header: HeaderView
5267
private lateinit var timeInputLayout: TextInputLayout
5368
private lateinit var timeInputEditText: TextInputEditText
54-
override lateinit var questionnaireViewItem: QuestionnaireViewItem
69+
lateinit var questionnaireViewItem: QuestionnaireViewItem
5570

56-
override fun init(itemView: View) {
71+
@Composable
72+
override fun Content(questionnaireViewItem: QuestionnaireViewItem) {
73+
val context = LocalContext.current
74+
val validationMessage =
75+
remember(questionnaireViewItem.validationResult) {
76+
getValidationErrorMessage(
77+
context,
78+
questionnaireViewItem,
79+
questionnaireViewItem.validationResult,
80+
)
81+
}
82+
val requiredOptionalText =
83+
remember(questionnaireViewItem) {
84+
getRequiredOrOptionalText(questionnaireViewItem, context)
85+
}
86+
val readOnly =
87+
remember(questionnaireViewItem.questionnaireItem) {
88+
questionnaireViewItem.questionnaireItem.readOnly
89+
}
90+
val questionnaireViewItemLocalTimeAnswer =
91+
remember(questionnaireViewItem.answers) {
92+
questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime
93+
}
94+
var questionnaireViewItemLocalTimeAnswerDisplay by
95+
remember(questionnaireViewItemLocalTimeAnswer) {
96+
mutableStateOf(questionnaireViewItemLocalTimeAnswer?.toLocalizedString(context))
97+
}
98+
val coroutineScope = rememberCoroutineScope { Dispatchers.Main }
99+
100+
Column(
101+
modifier =
102+
Modifier.padding(
103+
horizontal = dimensionResource(R.dimen.item_margin_horizontal),
104+
vertical = dimensionResource(R.dimen.item_margin_vertical),
105+
),
106+
) {
107+
Header(questionnaireViewItem)
108+
questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) }
109+
TimePickerItem(
110+
modifier = Modifier.fillMaxWidth(),
111+
selectedTime = questionnaireViewItemLocalTimeAnswerDisplay,
112+
enabled = !readOnly,
113+
hint = stringResource(R.string.time),
114+
supportingHelperText =
115+
if (!validationMessage.isNullOrBlank()) validationMessage else requiredOptionalText,
116+
isError = !validationMessage.isNullOrBlank(),
117+
) {
118+
questionnaireViewItemLocalTimeAnswerDisplay = it.toLocalizedString(context)
119+
coroutineScope.launch { setQuestionnaireItemViewItemAnswer(it) }
120+
}
121+
}
122+
}
123+
124+
fun init(itemView: View) {
57125
context = itemView.context.tryUnwrapContext()!!
58-
header = itemView.findViewById(R.id.header)
59126
timeInputLayout = itemView.findViewById(R.id.text_input_layout)
60127
timeInputEditText = itemView.findViewById(R.id.text_input_edit_text)
61128
timeInputEditText.inputType = InputType.TYPE_NULL
@@ -74,31 +141,6 @@ object TimePickerViewHolderFactory :
74141
}
75142
}
76143

77-
@SuppressLint("NewApi") // java.time APIs can be used due to desugaring
78-
override fun bind(questionnaireViewItem: QuestionnaireViewItem) {
79-
clearPreviousState()
80-
header.bind(questionnaireViewItem)
81-
timeInputLayout.helperText = getRequiredOrOptionalText(questionnaireViewItem, context)
82-
83-
val questionnaireItemViewItemDateTimeAnswer =
84-
questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime
85-
86-
// If there is no set answer in the QuestionnaireItemViewItem, make the time field empty.
87-
timeInputEditText.setText(
88-
questionnaireItemViewItemDateTimeAnswer?.toLocalizedString(timeInputEditText.context)
89-
?: "",
90-
)
91-
}
92-
93-
override fun setReadOnly(isReadOnly: Boolean) {
94-
// The system outside this delegate should only be able to mark it read only. Otherwise, it
95-
// will change the state set by this delegate in bindView().
96-
if (isReadOnly) {
97-
timeInputEditText.isEnabled = false
98-
timeInputLayout.isEnabled = false
99-
}
100-
}
101-
102144
private fun buildMaterialTimePicker(context: Context, inputMode: Int) {
103145
val selectedTime =
104146
questionnaireViewItem.answers.singleOrNull()?.valueTimeType?.localTime ?: LocalTime.now()
@@ -109,7 +151,7 @@ object TimePickerViewHolderFactory :
109151
TimeFormat.CLOCK_12H
110152
}
111153
MaterialTimePicker.Builder()
112-
.setTitleText(R.string.select_time)
154+
// .setTitleText(R.string.select_time)
113155
.setHour(selectedTime.hour)
114156
.setMinute(selectedTime.minute)
115157
.setTimeFormat(timeFormat)
@@ -119,7 +161,10 @@ object TimePickerViewHolderFactory :
119161
addOnPositiveButtonClickListener {
120162
with(LocalTime.of(this.hour, this.minute, 0)) {
121163
timeInputEditText.setText(this.toLocalizedString(context))
122-
setQuestionnaireItemViewItemAnswer(this)
164+
165+
context.tryUnwrapContext()?.lifecycleScope?.launch {
166+
setQuestionnaireItemViewItemAnswer(this@with)
167+
}
123168
timeInputEditText.clearFocus()
124169
}
125170
}
@@ -128,18 +173,11 @@ object TimePickerViewHolderFactory :
128173
}
129174

130175
/** Set the answer in the [QuestionnaireResponse]. */
131-
private fun setQuestionnaireItemViewItemAnswer(localDateTime: LocalTime) =
132-
context.lifecycleScope.launch {
133-
questionnaireViewItem.setAnswer(
134-
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent()
135-
.setValue(TimeType(localDateTime.format(DateTimeFormatter.ISO_TIME))),
136-
)
137-
}
138-
139-
private fun clearPreviousState() {
140-
timeInputEditText.isEnabled = true
141-
timeInputLayout.isEnabled = true
142-
}
176+
private suspend fun setQuestionnaireItemViewItemAnswer(localDateTime: LocalTime) =
177+
questionnaireViewItem.setAnswer(
178+
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent()
179+
.setValue(TimeType(localDateTime.format(DateTimeFormatter.ISO_TIME))),
180+
)
143181
}
144182

145183
private val TimeType.localTime

0 commit comments

Comments
 (0)