From 11efb05e54a6a8434685faa6dea3b7049a774146 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Sun, 10 Nov 2024 10:31:13 -0800 Subject: [PATCH] intermediate --- .../spezi/core/design/ValidationTest.kt | 44 ++++++++++ .../composables/DefaultValidationRules.kt | 38 ++++++++ .../composables/FocusValidationRules.kt | 63 +++++++++++++ .../DefaultValidationRulesSimulator.kt | 9 ++ .../FocusValidationRulesSimulator.kt | 50 +++++++++++ .../personalInfo/UserProfileComposable.kt | 10 ++- .../views/personalInfo/fields/NameFieldRow.kt | 3 - .../personalInfo/fields/NameTextField.kt | 1 - .../views/validation/ValidationEngine.kt | 59 ++++++++----- .../views/validation/ValidationModifier.kt | 10 +-- .../design/views/validation/ValidationRule.kt | 3 +- .../validation/ValidationRuleDefaults.kt | 4 +- .../state/CapturedValidationState.kt | 4 +- .../state/CapturedValidationStateEntries.kt | 6 +- .../state/FailedValidationResult.kt | 3 + .../validation/state/ReceiveValidation.kt | 12 ++- .../validation/state/ValidationContext.kt | 17 ++-- .../views/ValidationResultsComposable.kt | 24 ++++- .../validation/views/VerifiableTextField.kt | 88 ++++++++++++++++++- .../views/viewModifier/OnChangeListener.kt | 22 ----- .../viewModifier/viewState/ViewStateMapper.kt | 6 +- .../simulator/ContactComposableSimulator.kt | 2 +- 22 files changed, 396 insertions(+), 82 deletions(-) create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/ValidationTest.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/DefaultValidationRules.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/FocusValidationRules.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/DefaultValidationRulesSimulator.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/FocusValidationRulesSimulator.kt delete mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/OnChangeListener.kt diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/ValidationTest.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/ValidationTest.kt new file mode 100644 index 000000000..11e20aa2c --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/ValidationTest.kt @@ -0,0 +1,44 @@ +package edu.stanford.spezi.core.design + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import edu.stanford.spezi.core.design.composables.FocusValidationRules +import edu.stanford.spezi.core.design.simulator.FocusValidationRulesSimulator +import edu.stanford.spezi.core.design.views.validation.state.ValidationContext +import kotlinx.coroutines.delay +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ValidationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun init() { + composeTestRule.setContent { + FocusValidationRules() + } + } + + @Test + fun testValidationWithFocus() { + focusValidationRules { + assertHasEngines(true) + assertInputValid(false) + assertPasswordMessageExists(false) + assertEmptyMessageExists(false) + clickValidateButton() + assertLastState(false) + assertPasswordMessageExists(true) + // assertEmptyMessageExists(true) + } + } + + private fun focusValidationRules(block: FocusValidationRulesSimulator.() -> Unit) { + FocusValidationRulesSimulator(composeTestRule).apply(block) + } + +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/DefaultValidationRules.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/DefaultValidationRules.kt new file mode 100644 index 000000000..1f57e3b28 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/DefaultValidationRules.kt @@ -0,0 +1,38 @@ +package edu.stanford.spezi.core.design.composables + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.views.validation.Validate +import edu.stanford.spezi.core.design.views.validation.ValidationRule +import edu.stanford.spezi.core.design.views.validation.asciiLettersOnly +import edu.stanford.spezi.core.design.views.validation.mediumPassword +import edu.stanford.spezi.core.design.views.validation.minimalEmail +import edu.stanford.spezi.core.design.views.validation.minimalPassword +import edu.stanford.spezi.core.design.views.validation.nonEmpty +import edu.stanford.spezi.core.design.views.validation.strongPassword +import edu.stanford.spezi.core.design.views.validation.unicodeLettersOnly +import edu.stanford.spezi.core.design.views.validation.views.VerifiableTextField + +@Composable +fun DefaultValidationRules() { + val input = remember { mutableStateOf("") } + val rules = remember { + listOf( + ValidationRule.nonEmpty, + ValidationRule.unicodeLettersOnly, + ValidationRule.asciiLettersOnly, + ValidationRule.minimalEmail, + ValidationRule.minimalPassword, + ValidationRule.mediumPassword, + ValidationRule.strongPassword + ) + } + Validate(input.value, rules) { + VerifiableTextField( + StringResource("Field"), + text = input + ) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/FocusValidationRules.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/FocusValidationRules.kt new file mode 100644 index 000000000..57acecb0e --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/composables/FocusValidationRules.kt @@ -0,0 +1,63 @@ +package edu.stanford.spezi.core.design.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.views.validation.Validate +import edu.stanford.spezi.core.design.views.validation.ValidationRule +import edu.stanford.spezi.core.design.views.validation.minimalPassword +import edu.stanford.spezi.core.design.views.validation.nonEmpty +import edu.stanford.spezi.core.design.views.validation.state.ReceiveValidation +import edu.stanford.spezi.core.design.views.validation.state.ValidationContext +import edu.stanford.spezi.core.design.views.validation.views.VerifiableTextField + +enum class Field { + INPUT, NON_EMPTY_INPUT +} + +@Composable +fun FocusValidationRules() { + val input = remember { mutableStateOf("") } + val nonEmptyInput = remember { mutableStateOf("") } + val validationContext = remember { mutableStateOf(ValidationContext()) } + val lastValid = remember { mutableStateOf(null) } + val switchFocus = remember { mutableStateOf(false) } + + ReceiveValidation(validationContext) { + Column { + Text("Has Engines: ${if (!validationContext.value.isEmpty) "Yes" else "No"}") + Text("Input Valid: ${if (validationContext.value.allInputValid) "Yes" else "No"}") + lastValid.value?.let { lastValid -> + Text("Last state: ${if (lastValid) "valid" else "invalid"}") + } + Button( + onClick = { + val newLastValid = validationContext.value + .validateHierarchy(switchFocus.value) + lastValid.value = newLastValid + } + ) { + Text("Validate") + } + Row { + Text("Switch Focus") + Switch(switchFocus.value, onCheckedChange = { switchFocus.value = it }) + } + + Validate(input.value, rules = listOf(ValidationRule.minimalPassword)) { + VerifiableTextField(StringResource(Field.INPUT.name), input) + } + + Validate(nonEmptyInput.value, rules = listOf(ValidationRule.nonEmpty)) { + VerifiableTextField(StringResource(Field.NON_EMPTY_INPUT.name), nonEmptyInput) + } + } + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/DefaultValidationRulesSimulator.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/DefaultValidationRulesSimulator.kt new file mode 100644 index 000000000..36415d43d --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/DefaultValidationRulesSimulator.kt @@ -0,0 +1,9 @@ +package edu.stanford.spezi.core.design.simulator + +import androidx.compose.ui.test.junit4.ComposeTestRule + +class DefaultValidationRulesSimulator( + private val composeTestRule: ComposeTestRule, +) { + +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/FocusValidationRulesSimulator.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/FocusValidationRulesSimulator.kt new file mode 100644 index 000000000..78231742d --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/simulator/FocusValidationRulesSimulator.kt @@ -0,0 +1,50 @@ +package edu.stanford.spezi.core.design.simulator + +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick + +class FocusValidationRulesSimulator( + private val composeTestRule: ComposeTestRule, +) { + private val passwordMessage = "Your password must be at least 8 characters long." + private val emptyMessage = "This field cannot be empty." + + fun assertHasEngines(hasEngines: Boolean) { + composeTestRule + .onNodeWithText("Has Engines: ${if (hasEngines) "Yes" else "No"}") + .assertExists() + } + + fun assertInputValid(inputValid: Boolean) { + composeTestRule + .onNodeWithText("Input Valid: ${if (inputValid) "Yes" else "No"}") + .assertExists() + } + + fun assertPasswordMessageExists(exists: Boolean) { + val node = composeTestRule.onNodeWithText(passwordMessage) + if (exists) node.assertExists() else node.assertDoesNotExist() + } + + fun assertEmptyMessageExists(exists: Boolean) { + val node = composeTestRule.onNodeWithText(emptyMessage) + if (exists) node.assertExists() else node.assertDoesNotExist() + } + + fun clickValidateButton() { + composeTestRule + .onNodeWithText("Validate") + .assertHasClickAction() + .performClick() + } + + fun assertLastState(valid: Boolean) { + composeTestRule + .onNodeWithText("Last state: ${if (valid) "valid" else "invalid"}") + .assertExists() + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/UserProfileComposable.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/UserProfileComposable.kt index 9cf2a82c8..b3b1dfc94 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/UserProfileComposable.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/UserProfileComposable.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import edu.stanford.spezi.core.design.component.ImageResource +import edu.stanford.spezi.core.design.component.ImageResourceComposable import edu.stanford.spezi.core.design.theme.Colors import edu.stanford.spezi.core.design.theme.lighten import kotlin.math.min @@ -28,9 +30,9 @@ import kotlin.math.min fun UserProfileComposable( modifier: Modifier = Modifier, name: PersonNameComponents, - imageLoader: suspend () -> ImageVector? = { null }, // TODO: Use ImageResource instead! + imageLoader: suspend () -> ImageResource? = { null }, ) { - val image = remember { mutableStateOf(null) } + val image = remember { mutableStateOf(null) } val size = remember { mutableStateOf(IntSize.Zero) } LaunchedEffect(Unit) { @@ -41,9 +43,9 @@ fun UserProfileComposable( val sideLength = min(size.value.height, size.value.width).dp Box(modifier.size(sideLength, sideLength), contentAlignment = Alignment.Center) { image.value?.let { - Image( + ImageResourceComposable( it, - null, + "", // TODO: Add contentDescription to ImageResource directly? Modifier .clip(CircleShape) .background(Colors.background, CircleShape) diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameFieldRow.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameFieldRow.kt index be99e55f0..48102fe1f 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameFieldRow.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameFieldRow.kt @@ -1,10 +1,7 @@ package edu.stanford.spezi.core.design.views.personalInfo.fields import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid -import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameTextField.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameTextField.kt index c60be6459..5ffacc1cc 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameTextField.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalInfo/fields/NameTextField.kt @@ -1,6 +1,5 @@ package edu.stanford.spezi.core.design.views.personalInfo.fields -import android.app.Person import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.material3.TextField diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationEngine.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationEngine.kt index f20eb35c7..42f50e87d 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationEngine.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationEngine.kt @@ -1,58 +1,69 @@ package edu.stanford.spezi.core.design.views.validation -import android.provider.Settings.Global +import androidx.compose.runtime.mutableStateOf import edu.stanford.spezi.core.design.views.validation.configuration.DEFAULT_VALIDATION_DEBOUNCE_DURATION import edu.stanford.spezi.core.design.views.validation.state.FailedValidationResult -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import java.util.EnumSet import kotlin.time.Duration -typealias ValidationEngineConfiguration = EnumSet - -class ValidationEngine( - val rules: List, - var debounceDuration: Duration = DEFAULT_VALIDATION_DEBOUNCE_DURATION, - var configuration: ValidationEngineConfiguration = ValidationEngineConfiguration.noneOf(ConfigurationOption::class.java), -) { - private enum class Source { - SUBMIT, MANUAL - } +internal typealias ValidationEngineConfiguration = EnumSet +interface ValidationEngine { enum class ConfigurationOption { HIDE_FAILED_VALIDATION_ON_EMPTY_SUBMIT, CONSIDER_NO_INPUT_AS_VALID, } - var validationResults: List = emptyList() - private set + val rules: List + val inputValid: Boolean + val validationResults: List + val isDisplayingValidationErrors: Boolean + val displayedValidationResults: List + var debounceDuration: Duration + + fun submit(input: String, debounce: Boolean = false) + fun runValidation(input: String) +} + +internal class ValidationEngineImpl( + override val rules: List, + override var debounceDuration: Duration = DEFAULT_VALIDATION_DEBOUNCE_DURATION, + var configuration: ValidationEngineConfiguration = + ValidationEngineConfiguration.noneOf(ValidationEngine.ConfigurationOption::class.java), +) : ValidationEngine { + private enum class Source { + SUBMIT, MANUAL + } + + private var validationResultsState = mutableStateOf(emptyList()) + + override val validationResults get() = validationResultsState.value private var computedInputValid: Boolean? = null - val inputValid: Boolean get() = - computedInputValid ?: configuration.contains(ConfigurationOption.CONSIDER_NO_INPUT_AS_VALID) + override val inputValid: Boolean get() = + computedInputValid ?: configuration.contains(ValidationEngine.ConfigurationOption.CONSIDER_NO_INPUT_AS_VALID) private var source: Source? = null private var inputWasEmpty = true - val isDisplayingValidationErrors: Boolean get() { + override val isDisplayingValidationErrors: Boolean get() { val gotResults = validationResults.isNotEmpty() - if (configuration.contains(ConfigurationOption.HIDE_FAILED_VALIDATION_ON_EMPTY_SUBMIT)) { + if (configuration.contains(ValidationEngine.ConfigurationOption.HIDE_FAILED_VALIDATION_ON_EMPTY_SUBMIT)) { return gotResults && (source == Source.MANUAL || !inputWasEmpty) } return gotResults } - val displayedValidationResults: List get() = + override val displayedValidationResults: List get() = if (isDisplayingValidationErrors) validationResults else emptyList() private var debounceJob: Job? = null @@ -75,11 +86,11 @@ class ValidationEngine( this.source = source this.inputWasEmpty = input.isEmpty() - this.validationResults = computeFailedValidations(input) + this.validationResultsState.value = computeFailedValidations(input) this.computedInputValid = validationResults.isEmpty() } - fun submit(input: String, debounce: Boolean = false) { + override fun submit(input: String, debounce: Boolean) { if (!debounce || computedInputValid == false) { computeValidation(input, Source.SUBMIT) } else { @@ -89,7 +100,7 @@ class ValidationEngine( } } - fun runValidation(input: String) { + override fun runValidation(input: String) { computeValidation(input, Source.MANUAL) } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationModifier.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationModifier.kt index 560a39d90..02a87f526 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationModifier.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationModifier.kt @@ -3,6 +3,7 @@ package edu.stanford.spezi.core.design.views.validation import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import edu.stanford.spezi.core.design.component.StringResource @@ -37,23 +38,22 @@ fun Validate( rules: List, content: @Composable () -> Unit, ) { - val previousInput = remember { mutableStateOf(input) } val validationDebounce = LocalValidationDebounce.current val previousValidationDebounce = remember { mutableStateOf(null) } val validationEngineConfiguration = LocalValidationEngineConfiguration.current val previousValidationEngineConfiguration = remember { mutableStateOf(null) } - val engine = remember { ValidationEngine(rules, validationDebounce, validationEngineConfiguration) } + val engine = remember { ValidationEngineImpl(rules, validationDebounce, validationEngineConfiguration) } - if (input != previousInput.value) { + LaunchedEffect(input) { engine.submit(input, debounce = true) } - if (validationDebounce != previousValidationDebounce.value) { + LaunchedEffect(validationDebounce) { engine.debounceDuration = validationDebounce previousValidationDebounce.value = validationDebounce } - if (validationEngineConfiguration != previousValidationEngineConfiguration.value) { + LaunchedEffect(validationEngineConfiguration) { engine.configuration = validationEngineConfiguration previousValidationEngineConfiguration.value = validationEngineConfiguration } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRule.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRule.kt index 93fbdd97c..706ac77b5 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRule.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRule.kt @@ -2,10 +2,11 @@ package edu.stanford.spezi.core.design.views.validation import edu.stanford.spezi.core.design.component.StringResource import edu.stanford.spezi.core.design.views.validation.state.FailedValidationResult +import edu.stanford.spezi.core.utils.UUID import java.util.UUID data class ValidationRule internal constructor( - val id: UUID = UUID.randomUUID(), + val id: UUID = UUID(), val rule: (String) -> Boolean, val message: StringResource, val effect: CascadingValidationEffect = CascadingValidationEffect.CONTINUE diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRuleDefaults.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRuleDefaults.kt index 09c35fa73..a6aa86bc7 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRuleDefaults.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRuleDefaults.kt @@ -6,7 +6,7 @@ import java.nio.charset.StandardCharsets val ValidationRule.Companion.nonEmpty: ValidationRule get() = ValidationRule( regex = Regex(".*\\S+.*"), - message = StringResource("VALIDATION_RULE_NON_EMPTY") + message = StringResource("This field cannot be empty.") ) val ValidationRule.Companion.unicodeLettersOnly: ValidationRule @@ -30,7 +30,7 @@ val ValidationRule.Companion.minimalEmail: ValidationRule val ValidationRule.Companion.minimalPassword: ValidationRule get() = ValidationRule( regex = Regex(".{8,}"), - message = StringResource("VALIDATION_RULE_PASSWORD_LENGTH 8") + message = StringResource("Your password must be at least 8 characters long.") ) val ValidationRule.Companion.mediumPassword: ValidationRule diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationState.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationState.kt index 421d54bac..5288bb5f7 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationState.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationState.kt @@ -4,10 +4,10 @@ import androidx.compose.runtime.MutableState import edu.stanford.spezi.core.design.views.validation.ValidationEngine data class CapturedValidationState internal constructor( - internal val engine: ValidationEngine, + private val engine: ValidationEngine, private val input: String, private val isFocused: MutableState, -) { +) : ValidationEngine by engine { internal fun moveFocus() { isFocused.value = true } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationStateEntries.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationStateEntries.kt index a49c03814..53896bc3d 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationStateEntries.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationStateEntries.kt @@ -5,9 +5,11 @@ import androidx.compose.runtime.compositionLocalOf internal val LocalCapturedValidationStateEntries = compositionLocalOf { CapturedValidationStateEntries() } internal data class CapturedValidationStateEntries( - internal var entries: MutableList = mutableListOf() + private var _entries: MutableList = mutableListOf() ) { + val entries: List get() = _entries + fun add(state: CapturedValidationState) { - entries.add(state) + _entries.add(state) } } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/FailedValidationResult.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/FailedValidationResult.kt index ab25b0d5e..50ec874b8 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/FailedValidationResult.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/FailedValidationResult.kt @@ -12,4 +12,7 @@ data class FailedValidationResult( operator fun invoke(rule: ValidationRule) = FailedValidationResult(rule.id, rule.message) } + + override fun equals(other: Any?) = (other as? FailedValidationResult)?.id == id + override fun hashCode() = id.hashCode() } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ReceiveValidation.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ReceiveValidation.kt index 814f62cbc..94ce4e850 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ReceiveValidation.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ReceiveValidation.kt @@ -2,17 +2,21 @@ package edu.stanford.spezi.core.design.views.validation.state import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @Composable fun ReceiveValidation( state: MutableState, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { + // This is not remembered on purpose, since we are re-evaluating the validation here. val entries = CapturedValidationStateEntries() CompositionLocalProvider(LocalCapturedValidationStateEntries provides entries) { content() - // TODO: Possibly wrap this in a change listener instead. - state.value = ValidationContext(entries.entries) + + LaunchedEffect(entries.entries) { + state.value = ValidationContext(entries.entries) + } } -} \ No newline at end of file +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ValidationContext.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ValidationContext.kt index 80bf1c1ec..bcc392366 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ValidationContext.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ValidationContext.kt @@ -4,16 +4,16 @@ data class ValidationContext internal constructor( private val entries: List = emptyList() ) : Iterable { val allInputValid: Boolean get() = - entries.all { it.engine.inputValid } + entries.all { it.inputValid } val allValidationResults: List get() = - entries.fold(emptyList()) { acc, entry -> acc + entry.engine.validationResults } + entries.fold(emptyList()) { acc, entry -> acc + entry.validationResults } val allDisplayedValidationResults: List get() = - entries.fold(emptyList()) { acc, entry -> acc + entry.engine.displayedValidationResults } + entries.fold(emptyList()) { acc, entry -> acc + entry.displayedValidationResults } val isDisplayingValidationErrors: Boolean get() = - entries.any { it.engine.isDisplayingValidationErrors } + entries.any { it.isDisplayingValidationErrors } override fun iterator(): Iterator = entries.iterator() @@ -24,7 +24,7 @@ data class ValidationContext internal constructor( return mapNotNull { state -> state.runValidation() - if (state.engine.inputValid) state else null + if (!state.inputValid) state else null } } @@ -40,4 +40,11 @@ data class ValidationContext internal constructor( false } ?: true } + + override fun hashCode(): Int { + return super.hashCode() + } + + override fun equals(other: Any?): Boolean = + (other as? ValidationContext)?.entries == entries } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/ValidationResultsComposable.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/ValidationResultsComposable.kt index a3bb875fe..8be8a126a 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/ValidationResultsComposable.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/ValidationResultsComposable.kt @@ -1,4 +1,26 @@ package edu.stanford.spezi.core.design.views.validation.views -class ValidationResultsComposable { +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import edu.stanford.spezi.core.design.theme.TextStyles +import edu.stanford.spezi.core.design.views.validation.state.FailedValidationResult + +@Composable +fun ValidationResultsComposable( + results: List +) { + Column( + horizontalAlignment = Alignment.Start + ) { + for (result in results) { + Text( + result.message.text(), + style = TextStyles.labelSmall, + color = Color.Red, + ) + } + } } \ No newline at end of file diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/VerifiableTextField.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/VerifiableTextField.kt index 8790dc34a..985151613 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/VerifiableTextField.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/VerifiableTextField.kt @@ -1,4 +1,88 @@ package edu.stanford.spezi.core.design.views.validation.views -class VerifiableTextField { -} \ No newline at end of file +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.views.validation.configuration.LocalValidationEngine + +enum class TextFieldType { + TEXT, SECURE +} + +@Composable +fun VerifiableTextField( + label: StringResource, + text: MutableState, + type: TextFieldType = TextFieldType.TEXT, + disableAutocorrection: Boolean = false, + footer: @Composable () -> Unit = {} +) { + VerifiableTextField( + text, + type, + disableAutocorrection = disableAutocorrection, + { Text(label.text()) }, + footer + ) +} + +@Composable +fun VerifiableTextField( + text: MutableState, + type: TextFieldType = TextFieldType.TEXT, + disableAutocorrection: Boolean = false, + label: @Composable () -> Unit, + footer: @Composable () -> Unit = {} +) { + val validationEngine = LocalValidationEngine.current + + Column { + // TODO: Check if this is really equivalent, + // since iOS specifies this as a completely separate type + // and there we only have this visualTransformation property + when (type) { + TextFieldType.TEXT -> { + TextField( + text.value, + onValueChange = { text.value = it }, + label = label, + keyboardOptions = KeyboardOptions( + autoCorrect = !disableAutocorrection + ), + ) + } + TextFieldType.SECURE -> { + TextField( + text.value, + onValueChange = { text.value = it }, + label = label, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrect = !disableAutocorrection + ), + visualTransformation = PasswordVisualTransformation() + ) + } + } + + Row { + validationEngine?.let { + ValidationResultsComposable(it.displayedValidationResults) + + Spacer(Modifier.fillMaxWidth()) + } + + footer() + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/OnChangeListener.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/OnChangeListener.kt deleted file mode 100644 index 004d8b9be..000000000 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/OnChangeListener.kt +++ /dev/null @@ -1,22 +0,0 @@ -package edu.stanford.spezi.core.design.views.views.viewModifier - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember - -@Composable -fun OnChangeListener(state: T, initial: Boolean = false, block: (T?) -> Unit) { - if (initial) { - val previousValue = remember { mutableStateOf(null) } - - if (state != previousValue) { - block(previousValue.value) - } - } else { - val previousValue = remember { mutableStateOf(state) } - - if (state != previousValue) { - block(previousValue.value) - } - } -} \ No newline at end of file diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/viewState/ViewStateMapper.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/viewState/ViewStateMapper.kt index 6348b93bc..1ca174b1d 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/viewState/ViewStateMapper.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewModifier/viewState/ViewStateMapper.kt @@ -1,14 +1,14 @@ package edu.stanford.spezi.core.design.views.views.viewModifier.viewState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import edu.stanford.spezi.core.design.views.views.model.OperationState import edu.stanford.spezi.core.design.views.views.model.ViewState -import edu.stanford.spezi.core.design.views.views.viewModifier.OnChangeListener @Composable fun MapOperationStateToViewState(state: State, viewState: MutableState) { - OnChangeListener(state) { + LaunchedEffect(state) { viewState.value = state.representation } -} \ No newline at end of file +} diff --git a/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/simulator/ContactComposableSimulator.kt b/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/simulator/ContactComposableSimulator.kt index 69eaadcc3..7db3a40cc 100644 --- a/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/simulator/ContactComposableSimulator.kt +++ b/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/simulator/ContactComposableSimulator.kt @@ -10,11 +10,11 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.test.platform.app.InstrumentationRegistry import edu.stanford.spezi.core.design.component.ImageResource import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.views.personalInfo.PersonNameComponents import edu.stanford.spezi.core.testing.assertImageIdentifier import edu.stanford.spezi.core.testing.onNodeWithIdentifier import edu.stanford.spezi.modules.contact.ContactComposableTestIdentifier import edu.stanford.spezi.modules.contact.model.ContactOption -import edu.stanford.spezi.modules.contact.model.PersonNameComponents import edu.stanford.spezi.modules.contact.model.formatted class ContactComposableSimulator(