diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureForm.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureForm.kt index 151ce417e..cb054650d 100644 --- a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureForm.kt +++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/FeatureForm.kt @@ -33,12 +33,14 @@ import com.arcgismaps.mapping.featureforms.ComboBoxFormInput import com.arcgismaps.mapping.featureforms.DateTimePickerFormInput import com.arcgismaps.mapping.featureforms.FeatureForm import com.arcgismaps.mapping.featureforms.FieldFormElement +import com.arcgismaps.mapping.featureforms.RadioButtonsFormInput import com.arcgismaps.mapping.featureforms.TextAreaFormInput import com.arcgismaps.mapping.featureforms.TextBoxFormInput import com.arcgismaps.toolkit.featureforms.components.FieldElement import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState import com.arcgismaps.toolkit.featureforms.components.codedvalue.rememberCodedValueFieldState import com.arcgismaps.toolkit.featureforms.components.datetime.rememberDateTimeFieldState +import com.arcgismaps.toolkit.featureforms.components.codedvalue.rememberRadioButtonFieldState import com.arcgismaps.toolkit.featureforms.components.text.rememberFormTextFieldState import kotlinx.coroutines.CoroutineScope import java.util.Objects @@ -193,6 +195,14 @@ private fun rememberFieldStates( ) } + is RadioButtonsFormInput -> { + rememberRadioButtonFieldState( + field = fieldElement, + form = form, + scope = scope + ) + } + else -> { null } diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/FormElements.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/FormElements.kt index 1d7a38991..cd0f41507 100644 --- a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/FormElements.kt +++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/FormElements.kt @@ -7,7 +7,9 @@ import com.arcgismaps.mapping.featureforms.ComboBoxFormInput import com.arcgismaps.mapping.featureforms.DateTimePickerFormInput import com.arcgismaps.mapping.featureforms.FeatureForm import com.arcgismaps.mapping.featureforms.FieldFormElement +import com.arcgismaps.mapping.featureforms.FormInputNoValueOption import com.arcgismaps.mapping.featureforms.GroupFormElement +import com.arcgismaps.mapping.featureforms.RadioButtonsFormInput import com.arcgismaps.mapping.featureforms.TextAreaFormInput import com.arcgismaps.mapping.featureforms.TextBoxFormInput import com.arcgismaps.toolkit.featureforms.components.base.BaseFieldState @@ -15,6 +17,8 @@ import com.arcgismaps.toolkit.featureforms.components.codedvalue.ComboBoxField import com.arcgismaps.toolkit.featureforms.components.codedvalue.CodedValueFieldState import com.arcgismaps.toolkit.featureforms.components.datetime.DateTimeField import com.arcgismaps.toolkit.featureforms.components.datetime.DateTimeFieldState +import com.arcgismaps.toolkit.featureforms.components.codedvalue.RadioButtonField +import com.arcgismaps.toolkit.featureforms.components.codedvalue.RadioButtonFieldState import com.arcgismaps.toolkit.featureforms.components.text.FormTextField import com.arcgismaps.toolkit.featureforms.components.text.FormTextFieldState @@ -35,6 +39,14 @@ internal fun FieldElement(field: FieldFormElement, state: BaseFieldState) { ComboBoxField(state = state as CodedValueFieldState) } + is RadioButtonsFormInput -> { + if ((state as RadioButtonFieldState).shouldFallback()) { + ComboBoxField(state = state) + } else { + RadioButtonField(state = state) + } + } + else -> { /* TO-DO: add support for other input types */ } } diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonField.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonField.kt new file mode 100644 index 000000000..a98991b9a --- /dev/null +++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonField.kt @@ -0,0 +1,193 @@ +/* + * Copyright 2023 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.arcgismaps.toolkit.featureforms.components.codedvalue + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.arcgismaps.mapping.featureforms.FormInputNoValueOption +import com.arcgismaps.toolkit.featureforms.R + +@Composable +internal fun RadioButtonField( + state: RadioButtonFieldState, + modifier: Modifier = Modifier, + colors: RadioButtonFieldColors = RadioButtonFieldDefaults.colors() +) { + val value by state.value.collectAsState() + val editable by state.isEditable.collectAsState() + val required by state.isRequired.collectAsState() + val noValueLabel = state.noValueLabel.ifEmpty { stringResource(R.string.no_value) } + RadioButtonField( + label = state.label, + description = state.description, + value = value, + editable = editable, + required = required, + codedValues = state.codedValues.associateBy({ it.code }, { it.name }), + showNoValueOption = state.showNoValueOption, + noValueLabel = noValueLabel, + modifier = modifier, + colors = colors + ) { + state.onValueChanged(it) + } +} + +@Composable +private fun RadioButtonField( + label: String, + description: String, + value: String, + editable: Boolean, + required: Boolean, + codedValues: Map, + showNoValueOption: FormInputNoValueOption, + noValueLabel: String, + modifier: Modifier = Modifier, + colors: RadioButtonFieldColors = RadioButtonFieldDefaults.colors(), + onValueChanged: (String) -> Unit = {} +) { + val options = if (!required) { + if (showNoValueOption == FormInputNoValueOption.Show) { + mapOf("" to noValueLabel) + codedValues + } else codedValues + } else codedValues + + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = 15.dp, end = 15.dp, top = 10.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = if (required) { + "$label *" + } else { + label + }, + style = MaterialTheme.typography.bodyMedium, + color = colors.labelColor(enabled = editable) + ) + Column( + modifier = Modifier + .selectableGroup() + .border( + width = 1.dp, + color = colors.containerBorderColor(enabled = editable), + shape = RoundedCornerShape(5.dp) + ) + ) { + CompositionLocalProvider( + LocalContentColor provides colors.textColor(enabled = editable) + ) { + options.forEach { (code, name) -> + RadioButtonRow( + value = name, + selected = (code?.toString() + ?: "") == value || (name == noValueLabel && value.isEmpty()), + enabled = editable, + onClick = { onValueChanged(code?.toString() ?: "") } + ) + } + } + } + if (description.isNotEmpty()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = colors.supportingTextColor(enabled = editable) + ) + } + } + +} + +@Composable +private fun RadioButtonRow( + value: String, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = onClick, + ) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + RadioButton( + selected = selected, + onClick = null, + enabled = enabled + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, showSystemUi = true) +@Composable +private fun RadioButtonFieldPreview() { + MaterialTheme { + RadioButtonField( + label = "A list of values", + description = "Description", + value = "", + editable = true, + required = true, + codedValues = mapOf( + "One" to "One", + "Two" to "Two", + "Three" to "Three" + ), + showNoValueOption = FormInputNoValueOption.Show, + noValueLabel = "No Value", + ) { } + } +} diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldDefaults.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldDefaults.kt new file mode 100644 index 000000000..26c9ed1ae --- /dev/null +++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldDefaults.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2023 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.arcgismaps.toolkit.featureforms.components.codedvalue + +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +internal object RadioButtonFieldDefaults { + + private const val textDisabledAlpha = 0.38f + private const val containerDisabledAlpha = 0.12f + + @Composable + fun colors(): RadioButtonFieldColors = RadioButtonFieldColors( + defaultLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy( + alpha = textDisabledAlpha + ), + defaultSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledSupportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = textDisabledAlpha + ), + errorColor = MaterialTheme.colorScheme.error, + defaultContainerBorderColor = MaterialTheme.colorScheme.outline, + disabledContainerBorderColor = MaterialTheme.colorScheme.outline.copy( + alpha = containerDisabledAlpha + ), + defaultTextColor = LocalContentColor.current, + disabledTextColor = LocalContentColor.current.copy(alpha = textDisabledAlpha) + ) +} + +internal data class RadioButtonFieldColors( + val defaultLabelColor: Color, + val disabledLabelColor: Color, + val defaultSupportingTextColor: Color, + val disabledSupportingTextColor: Color, + val errorColor: Color, + val defaultContainerBorderColor: Color, + val disabledContainerBorderColor: Color, + val defaultTextColor: Color, + val disabledTextColor: Color +) { + /** + * Represents the color used for the label of this radio button field. + * + * @param enabled whether the field is enabled + */ + @Composable + fun labelColor(enabled: Boolean): Color { + return if (enabled) { + defaultLabelColor + } else { + disabledLabelColor + } + } + + /** + * Represents the color used for the supporting text of this radio button field. + * + * @param enabled whether the field is enabled + */ + @Composable + fun supportingTextColor(enabled: Boolean): Color { + return if (enabled) { + defaultSupportingTextColor + } else { + disabledSupportingTextColor + } + } + + /** + * Represents the color used for the container border of this radio button field. + * + * @param enabled whether the field is enabled + */ + @Composable + fun containerBorderColor(enabled: Boolean): Color { + return if (enabled) { + defaultContainerBorderColor + } else { + disabledContainerBorderColor + } + } + + /** + * Represents the color used for the text of this radio button field options. + * + * @param enabled whether the field is enabled + */ + @Composable + fun textColor(enabled: Boolean): Color { + return if (enabled) { + defaultTextColor + } else { + disabledTextColor + } + } +} diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldState.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldState.kt new file mode 100644 index 000000000..d13738c0c --- /dev/null +++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/components/codedvalue/RadioButtonFieldState.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2023 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.arcgismaps.toolkit.featureforms.components.codedvalue + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import com.arcgismaps.mapping.featureforms.FeatureForm +import com.arcgismaps.mapping.featureforms.FieldFormElement +import com.arcgismaps.mapping.featureforms.RadioButtonsFormInput +import com.arcgismaps.toolkit.featureforms.utils.editValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal typealias RadioButtonFieldProperties = CodedValueFieldProperties + +internal class RadioButtonFieldState( + properties: RadioButtonFieldProperties, + initialValue: String = properties.value.value, + scope: CoroutineScope, + onEditValue: ((Any?) -> Unit) +) : CodedValueFieldState( + properties = properties, + initialValue = initialValue, + scope = scope, + onEditValue = onEditValue +) { + + /** + * Returns true if the current value of [value] is not in the [codedValues]. This should + * trigger a fallback to a ComboBox. If the [value] is empty then this returns false. + */ + fun shouldFallback(): Boolean { + return if (value.value.isEmpty()) { + false + } else { + !codedValues.any { + it.code.toString() == value.value + } + } + } + + companion object { + + /** + * Default saver for the [RadioButtonFieldState]. + */ + fun Saver( + formElement: FieldFormElement, + form: FeatureForm, + scope: CoroutineScope + ): Saver = listSaver( + save = { + listOf( + it.value.value + ) + }, + restore = { list -> + val input = formElement.input as RadioButtonsFormInput + RadioButtonFieldState( + properties = RadioButtonFieldProperties( + label = formElement.label, + placeholder = formElement.hint, + description = formElement.description, + value = formElement.value, + editable = formElement.isEditable, + required = formElement.isRequired, + codedValues = input.codedValues, + showNoValueOption = input.noValueOption, + noValueLabel = input.noValueLabel + ), + initialValue = list[0], + scope = scope, + onEditValue = { newValue -> + form.editValue(formElement, newValue) + scope.launch { form.evaluateExpressions() } + } + ) + } + ) + } +} + +@Composable +internal fun rememberRadioButtonFieldState( + field: FieldFormElement, + form: FeatureForm, + scope: CoroutineScope +): RadioButtonFieldState = rememberSaveable( + saver = RadioButtonFieldState.Saver(field, form, scope) +) { + val input = field.input as RadioButtonsFormInput + RadioButtonFieldState( + properties = RadioButtonFieldProperties( + label = field.label, + placeholder = field.hint, + description = field.description, + value = field.value, + editable = field.isEditable, + required = field.isRequired, + codedValues = input.codedValues, + showNoValueOption = input.noValueOption, + noValueLabel = input.noValueLabel + ), + scope = scope, + onEditValue = { + form.editValue(field, it) + scope.launch { form.evaluateExpressions() } + } + ) +}