From f5ee17f3cdf14b3af652c9ddec8faea0954b55e1 Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 24 Nov 2024 05:28:11 -0800 Subject: [PATCH] SpeziViews (#140) # SpeziViews ## :recycle: Current situation & Problem *Link any open issues or pull requests (PRs) related to this PR. Please ensure that all non-trivial PRs are first tracked and discussed in an existing GitHub issue or discussion.* ## :gear: Release Notes *Add a bullet point list summary of the feature and possible migration guides if this is a breaking change so this section can be added to the release notes.* *Include code snippets that provide examples of the feature implemented or links to the documentation if it appends or changes the public interface.* ## :books: Documentation *Please ensure that you properly document any additions in conformance to [Spezi Documentation Guide](https://github.com/StanfordSpezi/.github/blob/main/DOCUMENTATIONGUIDE.md).* *You can use this section to describe your solution, but we encourage contributors to document your reasoning and changes using in-line documentation.* ## :white_check_mark: Testing *Please ensure that the PR meets the testing requirements set by CodeCov and that new functionality is appropriately tested.* *This section describes important information about the tests and why some elements might not be testable.* ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --- .../data/ContactDocumentToContactMapper.kt | 13 +- .../bdh/engagehf/contact/ui/ContactScreen.kt | 12 +- .../ContactDocumentToContactMapperTest.kt | 2 +- core/design/build.gradle.kts | 1 + .../design/personalInfo/NameFieldsTest.kt | 49 ++++++ .../design/personalInfo/UserProfileTest.kt | 35 +++++ .../composables/NameFieldsTestComposable.kt | 23 +++ .../composables/UserProfileTestComposable.kt | 38 +++++ .../simulators/NameFieldsTestSimulator.kt | 25 +++ .../simulators/UserProfileTestSimulator.kt | 35 +++++ .../validation/FocusValidationRulesTest.kt | 45 ++++++ .../composables/FocusValidationRules.kt | 76 +++++++++ .../FocusValidationRulesSimulator.kt | 63 ++++++++ .../spezi/core/design/views/MarkdownTest.kt | 42 +++++ .../core/design/views/SuspendButtonTest.kt | 39 +++++ .../composables/MarkdownTestComposable.kt | 24 +++ .../SuspendButtonTestComposable.kt | 45 ++++++ .../views/simulators/MarkdownTestSimulator.kt | 25 +++ .../simulators/SuspendButtonTestSimulator.kt | 70 +++++++++ .../design/component/AsyncImageResource.kt | 47 ++++++ .../component/AsyncImageResourceComposable.kt | 114 ++++++++++++++ .../core/design/component/ImageResource.kt | 22 ++- .../component/ImageResourceComposable.kt | 54 +------ .../component/markdown/MarkdownElement.kt | 10 +- .../personalinfo/PersonNameComponents.kt | 64 ++++++++ .../personalinfo/UserProfileComposable.kt | 126 +++++++++++++++ .../views/personalinfo/fields/NameFieldRow.kt | 83 ++++++++++ .../personalinfo/fields/NameTextField.kt | 76 +++++++++ .../validation/CascadingValidationEffect.kt | 5 + .../views/validation/ValidationEngine.kt | 124 +++++++++++++++ .../views/validation/ValidationModifier.kt | 86 ++++++++++ .../design/views/validation/ValidationRule.kt | 36 +++++ .../validation/ValidationRuleDefaults.kt | 46 ++++++ .../configuration/ValidationEngine.kt | 6 + .../ValidationEngineConfiguration.kt | 12 ++ .../state/CapturedValidationState.kt | 27 ++++ .../state/CapturedValidationStateEntries.kt | 15 ++ .../state/FailedValidationResult.kt | 18 +++ .../validation/state/ReceiveValidation.kt | 22 +++ .../validation/state/ValidationContext.kt | 49 ++++++ .../views/ValidationResultsComposable.kt | 44 ++++++ .../validation/views/VerifiableTextField.kt | 148 ++++++++++++++++++ .../views/views/layout/DescriptionGridRow.kt | 77 +++++++++ .../views/views/model/OperationState.kt | 5 + .../design/views/views/model/ViewState.kt | 17 ++ .../views/views/button/ProcessingOverlay.kt | 70 +++++++++ .../views/views/views/button/SuspendButton.kt | 93 +++++++++++ .../design/views/views/views/text/Markdown.kt | 92 +++++++++++ .../views/viewstate/OperationStateAlert.kt | 37 +++++ .../views/views/viewstate/ViewStateAlert.kt | 61 ++++++++ .../views/views/viewstate/ViewStateMapper.kt | 18 +++ .../spezi/core/design/SpeziValidationTest.kt | 32 ++++ .../spezi/modules/contact/ContactFactory.kt | 16 +- .../simulator/ContactComposableSimulator.kt | 4 +- .../modules/contact/ContactComposable.kt | 11 +- .../spezi/modules/contact/model/Contact.kt | 4 +- .../contact/model/PersonNameComponents.kt | 22 --- .../component/ExpandableVideoSection.kt | 10 +- 58 files changed, 2355 insertions(+), 110 deletions(-) create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/NameFieldsTest.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/UserProfileTest.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/NameFieldsTestComposable.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/UserProfileTestComposable.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/NameFieldsTestSimulator.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/UserProfileTestSimulator.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/FocusValidationRulesTest.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/composables/FocusValidationRules.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/simulators/FocusValidationRulesSimulator.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/MarkdownTest.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/SuspendButtonTest.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/MarkdownTestComposable.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/SuspendButtonTestComposable.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/MarkdownTestSimulator.kt create mode 100644 core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/SuspendButtonTestSimulator.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResource.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResourceComposable.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/PersonNameComponents.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/UserProfileComposable.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/fields/NameFieldRow.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/fields/NameTextField.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/CascadingValidationEffect.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationEngine.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationModifier.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRule.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRuleDefaults.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngine.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngineConfiguration.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationState.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationStateEntries.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/FailedValidationResult.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ReceiveValidation.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ValidationContext.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/ValidationResultsComposable.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/VerifiableTextField.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/layout/DescriptionGridRow.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/OperationState.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/ViewState.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/ProcessingOverlay.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/SuspendButton.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/text/Markdown.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/OperationStateAlert.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateAlert.kt create mode 100644 core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateMapper.kt create mode 100644 core/design/src/test/kotlin/edu/stanford/spezi/core/design/SpeziValidationTest.kt delete mode 100644 modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/PersonNameComponents.kt diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/data/ContactDocumentToContactMapper.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/data/ContactDocumentToContactMapper.kt index a5bc024ca..7da150eaa 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/data/ContactDocumentToContactMapper.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/data/ContactDocumentToContactMapper.kt @@ -2,9 +2,9 @@ package edu.stanford.bdh.engagehf.contact.data import com.google.firebase.firestore.DocumentSnapshot import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.views.personalinfo.PersonNameComponents import edu.stanford.spezi.modules.contact.model.Contact import edu.stanford.spezi.modules.contact.model.ContactOption -import edu.stanford.spezi.modules.contact.model.PersonNameComponents import edu.stanford.spezi.modules.contact.model.call import edu.stanford.spezi.modules.contact.model.email import javax.inject.Inject @@ -19,11 +19,12 @@ class ContactDocumentToContactMapper @Inject constructor() { } val components = contactName.split(", ") val nameComponents = components.firstOrNull()?.split(" ") - val personNameComponents = PersonNameComponents( - givenName = nameComponents?.getOrNull(0), - familyName = nameComponents?.drop(1) - ?.joinToString(" ") // assigning everything besides given name here - ) + val personNameComponents = + PersonNameComponents( + givenName = nameComponents?.getOrNull(0), + familyName = nameComponents?.drop(1) + ?.joinToString(" ") // assigning everything besides given name here + ) val title = components.lastOrNull() val contactEmail = document.getString(CONTACT_EMAIL_FIELD) val phone = document.getString(CONTACT_PHONE_FIELD) diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/ui/ContactScreen.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/ui/ContactScreen.kt index 43233085c..634a2e1f4 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/ui/ContactScreen.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/contact/ui/ContactScreen.kt @@ -29,11 +29,11 @@ import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.SpeziTheme import edu.stanford.spezi.core.design.theme.TextStyles import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.personalinfo.PersonNameComponents import edu.stanford.spezi.core.notification.R import edu.stanford.spezi.modules.contact.ContactComposable import edu.stanford.spezi.modules.contact.model.Contact import edu.stanford.spezi.modules.contact.model.ContactOption -import edu.stanford.spezi.modules.contact.model.PersonNameComponents import edu.stanford.spezi.modules.contact.model.call import edu.stanford.spezi.modules.contact.model.email import edu.stanford.spezi.modules.contact.model.website @@ -109,8 +109,14 @@ private class ContactUiStateProvider : PreviewParameterProvider Unit) { + NameFieldsTestSimulator(composeTestRule).apply(block) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/UserProfileTest.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/UserProfileTest.kt new file mode 100644 index 000000000..f92376c0f --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/UserProfileTest.kt @@ -0,0 +1,35 @@ +package edu.stanford.spezi.core.design.personalInfo + +import androidx.compose.ui.test.junit4.createComposeRule +import edu.stanford.spezi.core.design.personalInfo.composables.UserProfileTestComposable +import edu.stanford.spezi.core.design.personalInfo.simulators.UserProfileTestSimulator +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class UserProfileTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun init() { + composeTestRule.setContent { + UserProfileTestComposable() + } + } + + @Test + fun testUserProfile() { + userProfile { + assertUserInitialsExists(true, "PS") + assertUserInitialsExists(true, "LS") + waitUntilUserInitialsDisappear("LS") + assertImageExists("Person") + } + } + + private fun userProfile(block: UserProfileTestSimulator.() -> Unit) { + UserProfileTestSimulator(composeTestRule).apply(block) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/NameFieldsTestComposable.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/NameFieldsTestComposable.kt new file mode 100644 index 000000000..af1212881 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/NameFieldsTestComposable.kt @@ -0,0 +1,23 @@ +package edu.stanford.spezi.core.design.personalInfo.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import edu.stanford.spezi.core.design.views.personalinfo.PersonNameComponents +import edu.stanford.spezi.core.design.views.personalinfo.fields.NameFieldRow + +@Composable +fun NameFieldsTestComposable(nameBuilder: PersonNameComponents.Builder) { + Column { + NameFieldRow("First Name", nameBuilder, PersonNameComponents.Builder::givenName) { + Text("enter your first name") + } + + HorizontalDivider() + + NameFieldRow("Last Name", nameBuilder, PersonNameComponents.Builder::familyName) { + Text("enter your last name") + } + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/UserProfileTestComposable.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/UserProfileTestComposable.kt new file mode 100644 index 000000000..6e51756ef --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/composables/UserProfileTestComposable.kt @@ -0,0 +1,38 @@ +package edu.stanford.spezi.core.design.personalInfo.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +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.design.views.personalinfo.UserProfileComposable +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +@Composable +fun UserProfileTestComposable() { + Column { + UserProfileComposable( + PersonNameComponents( + givenName = "Paul", + familyName = "Schmiedmayer" + ), + Modifier.height(100.dp), + ) + UserProfileComposable( + PersonNameComponents( + givenName = "Leland", + familyName = "Stanford" + ), + Modifier.height(200.dp), + ) { + delay(0.5.seconds) + ImageResource.Vector(Icons.Default.Person, StringResource("Person")) + } + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/NameFieldsTestSimulator.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/NameFieldsTestSimulator.kt new file mode 100644 index 000000000..9c671de20 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/NameFieldsTestSimulator.kt @@ -0,0 +1,25 @@ +package edu.stanford.spezi.core.design.personalInfo.simulators + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import edu.stanford.spezi.core.design.views.personalinfo.PersonNameComponents +import edu.stanford.spezi.core.design.views.personalinfo.fields.NameTextFieldTestIdentifier +import edu.stanford.spezi.core.testing.onNodeWithIdentifier +import kotlin.reflect.KMutableProperty1 + +class NameFieldsTestSimulator( + private val composeTestRule: ComposeTestRule, +) { + fun assertTextExists(text: String) { + composeTestRule + .onNodeWithText(text) + .assertExists() + } + + fun enterText(property: KMutableProperty1, text: String) { + composeTestRule + .onNodeWithIdentifier(NameTextFieldTestIdentifier.TEXT_FIELD, property.name) + .performTextInput(text) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/UserProfileTestSimulator.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/UserProfileTestSimulator.kt new file mode 100644 index 000000000..7ad6657c4 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/personalInfo/simulators/UserProfileTestSimulator.kt @@ -0,0 +1,35 @@ +package edu.stanford.spezi.core.design.personalInfo.simulators + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText + +class UserProfileTestSimulator( + private val composeTestRule: ComposeTestRule, +) { + + fun assertUserInitialsExists(exists: Boolean, text: String) { + val node = composeTestRule + .onNodeWithText(text) + if (exists) { + node.assertExists() + } else { + node.assertDoesNotExist() + } + } + + fun waitUntilUserInitialsDisappear(text: String, timeoutMillis: Long = 1_000) { + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.onAllNodesWithText(text) + .fetchSemanticsNodes().isEmpty() + } + } + + fun assertImageExists(contentDescription: String) { + composeTestRule + .onAllNodesWithContentDescription(contentDescription) + .assertCountEquals(1) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/FocusValidationRulesTest.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/FocusValidationRulesTest.kt new file mode 100644 index 000000000..2f8536da4 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/FocusValidationRulesTest.kt @@ -0,0 +1,45 @@ +package edu.stanford.spezi.core.design.validation + +import androidx.compose.ui.test.junit4.createComposeRule +import edu.stanford.spezi.core.design.validation.composables.FocusValidationRules +import edu.stanford.spezi.core.design.validation.simulators.FocusValidationRulesSimulator +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class FocusValidationRulesTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun init() { + composeTestRule.setContent { + FocusValidationRules() + } + } + + @Test + fun testFocusValidationRules() { + focusValidationRules { + assertHasEngines(true) + assertInputValid(false) + assertPasswordMessageExists(false) + assertEmptyMessageExists(false) + clickValidateButton() + assertLastState(false) + assertPasswordMessageExists(true) + assertEmptyMessageExists(true) + enterEmail("leland@stanford.edu") + assertEmptyMessageExists(false) + assertPasswordMessageExists(true) + enterPassword("password") + assertEmptyMessageExists(false) + assertPasswordMessageExists(false) + } + } + + private fun focusValidationRules(block: FocusValidationRulesSimulator.() -> Unit) { + FocusValidationRulesSimulator(composeTestRule).apply(block) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/composables/FocusValidationRules.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/composables/FocusValidationRules.kt new file mode 100644 index 000000000..91537dfbb --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/composables/FocusValidationRules.kt @@ -0,0 +1,76 @@ +package edu.stanford.spezi.core.design.validation.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.ui.Modifier +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 +import edu.stanford.spezi.core.utils.extensions.testIdentifier + +enum class Field { + INPUT, NON_EMPTY_INPUT +} + +enum class FocusValidationRulesTestIdentifier { + EMAIL_TEXTFIELD, PASSWORD_TEXTFIELD +} + +@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(nonEmptyInput.value, rules = listOf(ValidationRule.nonEmpty)) { + VerifiableTextField( + StringResource(Field.NON_EMPTY_INPUT.name), + nonEmptyInput, + Modifier.testIdentifier(FocusValidationRulesTestIdentifier.EMAIL_TEXTFIELD) + ) + } + + Validate(input.value, rules = listOf(ValidationRule.minimalPassword)) { + VerifiableTextField( + StringResource(Field.INPUT.name), + input, + Modifier.testIdentifier(FocusValidationRulesTestIdentifier.PASSWORD_TEXTFIELD) + ) + } + } + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/simulators/FocusValidationRulesSimulator.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/simulators/FocusValidationRulesSimulator.kt new file mode 100644 index 000000000..0e0a4affc --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/validation/simulators/FocusValidationRulesSimulator.kt @@ -0,0 +1,63 @@ +package edu.stanford.spezi.core.design.validation.simulators + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import edu.stanford.spezi.core.design.validation.composables.FocusValidationRulesTestIdentifier +import edu.stanford.spezi.core.testing.onNodeWithIdentifier + +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() + } + + fun enterEmail(text: String) { + composeTestRule + .onNodeWithIdentifier(FocusValidationRulesTestIdentifier.EMAIL_TEXTFIELD) + .performTextInput(text) + } + + fun enterPassword(text: String) { + composeTestRule + .onNodeWithIdentifier(FocusValidationRulesTestIdentifier.PASSWORD_TEXTFIELD) + .performTextInput(text) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/MarkdownTest.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/MarkdownTest.kt new file mode 100644 index 000000000..56a568be3 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/MarkdownTest.kt @@ -0,0 +1,42 @@ +package edu.stanford.spezi.core.design.views + +import androidx.compose.ui.test.junit4.createComposeRule +import edu.stanford.spezi.core.design.views.composables.MarkdownTestComposable +import edu.stanford.spezi.core.design.views.simulators.MarkdownTestSimulator +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class MarkdownTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun init() { + composeTestRule.setContent { + MarkdownTestComposable() + } + } + + @Test + fun testMarkdown() { + markdown { + waitForTextToAppear( + "This is a markdown example.", + timeoutMillis = 100 + ) + assertTextExists( + "This is a markdown example taking half a second to load.", + exists = false + ) + waitForTextToAppear( + "This is a markdown example taking half a second to load.", + ) + } + } + + private fun markdown(block: MarkdownTestSimulator.() -> Unit) { + MarkdownTestSimulator(composeTestRule).apply(block) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/SuspendButtonTest.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/SuspendButtonTest.kt new file mode 100644 index 000000000..f5df1198a --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/SuspendButtonTest.kt @@ -0,0 +1,39 @@ +package edu.stanford.spezi.core.design.views + +import androidx.compose.ui.test.junit4.createComposeRule +import edu.stanford.spezi.core.design.views.composables.SuspendButtonTestComposable +import edu.stanford.spezi.core.design.views.simulators.SuspendButtonTestSimulator +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class SuspendButtonTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun init() { + composeTestRule.setContent { + SuspendButtonTestComposable() + } + } + + @Test + fun testSuspendButton() { + suspendButton { + clickHelloWorldButton() + waitForHelloWorldButtonAction() + resetHelloWorldButtonAction() + + clickHelloThrowingWorldButton() + assertViewStateAlertAppeared("Error was thrown!") + dismissViewStateAlert() + assertHelloThrowingWorldButtonIsEnabled() + } + } + + private fun suspendButton(block: SuspendButtonTestSimulator.() -> Unit) { + SuspendButtonTestSimulator(composeTestRule).apply(block) + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/MarkdownTestComposable.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/MarkdownTestComposable.kt new file mode 100644 index 000000000..3d0c7c7c9 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/MarkdownTestComposable.kt @@ -0,0 +1,24 @@ +package edu.stanford.spezi.core.design.views.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import edu.stanford.spezi.core.design.views.views.views.text.MarkdownBytes +import edu.stanford.spezi.core.design.views.views.views.text.MarkdownString +import kotlinx.coroutines.delay +import java.nio.charset.StandardCharsets +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun MarkdownTestComposable() { + Column { + MarkdownBytes( + bytes = { + delay(500.milliseconds) + "This is a markdown **example** taking half a second to load." + .toByteArray(StandardCharsets.UTF_8) + } + ) + + MarkdownString("This is a markdown **example**.") + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/SuspendButtonTestComposable.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/SuspendButtonTestComposable.kt new file mode 100644 index 000000000..1badf7e1d --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/composables/SuspendButtonTestComposable.kt @@ -0,0 +1,45 @@ +package edu.stanford.spezi.core.design.views.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import edu.stanford.spezi.core.design.views.views.model.ViewState +import edu.stanford.spezi.core.design.views.views.views.button.SuspendButton +import edu.stanford.spezi.core.design.views.views.viewstate.ViewStateAlert +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +class CustomError : Throwable() { + override val message = "Error was thrown!" +} + +@Composable +fun SuspendButtonTestComposable() { + var showCompleted by remember { mutableStateOf(false) } + val viewState = remember { mutableStateOf(ViewState.Idle) } + + ViewStateAlert(viewState) + + Column { + if (showCompleted) { + Text("Action executed") + Button(onClick = { showCompleted = false }) { + Text("Reset") + } + } else { + SuspendButton("Hello World") { + delay(500.milliseconds) + showCompleted = true + } + + SuspendButton("Hello Throwing World", viewState) { + throw CustomError() + } + } + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/MarkdownTestSimulator.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/MarkdownTestSimulator.kt new file mode 100644 index 000000000..8acbf488f --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/MarkdownTestSimulator.kt @@ -0,0 +1,25 @@ +package edu.stanford.spezi.core.design.views.simulators + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText + +class MarkdownTestSimulator( + private val composeTestRule: ComposeTestRule, +) { + fun assertTextExists(text: String, exists: Boolean = true) { + val node = composeTestRule.onNodeWithText(text) + if (exists) { + node.assertExists() + } else { + node.assertDoesNotExist() + } + } + + fun waitForTextToAppear(text: String, timeoutMillis: Long = 1_000) { + composeTestRule.waitUntil(timeoutMillis) { + composeTestRule.onAllNodesWithText(text) + .fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/SuspendButtonTestSimulator.kt b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/SuspendButtonTestSimulator.kt new file mode 100644 index 000000000..8542f64f4 --- /dev/null +++ b/core/design/src/androidTest/kotlin/edu/stanford/spezi/core/design/views/simulators/SuspendButtonTestSimulator.kt @@ -0,0 +1,70 @@ +package edu.stanford.spezi.core.design.views.simulators + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick + +class SuspendButtonTestSimulator( + private val composeTestRule: ComposeTestRule, +) { + + fun clickHelloWorldButton() { + composeTestRule + .onNodeWithText("Hello World") + .assertHasClickAction() + .performClick() + } + + fun waitForHelloWorldButtonAction() { + composeTestRule.waitUntil { + composeTestRule.onAllNodesWithText("Action executed") + .fetchSemanticsNodes().size == 1 + } + + composeTestRule + .onNodeWithText("Action executed") + .assertExists() + } + + fun resetHelloWorldButtonAction() { + composeTestRule + .onNodeWithText("Reset") + .assertHasClickAction() + .performClick() + } + + fun clickHelloThrowingWorldButton() { + composeTestRule + .onNodeWithText("Hello Throwing World") + .assertHasClickAction() + .assertIsEnabled() + .performClick() + } + + fun assertViewStateAlertAppeared(message: String) { + composeTestRule + .onNodeWithText("Error") + .assertExists() + + composeTestRule + .onNodeWithText(message) + .assertExists() + } + + fun dismissViewStateAlert() { + composeTestRule + .onNodeWithText("OK") + .assertHasClickAction() + .performClick() + } + + fun assertHelloThrowingWorldButtonIsEnabled() { + composeTestRule + .onNodeWithText("Hello Throwing World") + .assertHasClickAction() + .assertIsEnabled() + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResource.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResource.kt new file mode 100644 index 000000000..1707e4edc --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResource.kt @@ -0,0 +1,47 @@ +package edu.stanford.spezi.core.design.component + +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.vector.ImageVector +import edu.stanford.spezi.core.utils.UUID +import java.util.UUID +import javax.annotation.concurrent.Immutable + +@Immutable +sealed interface AsyncImageResource { + val identifier: String + val contentDescription: StringResource + + data class Remote( + val url: String, + override val contentDescription: StringResource, + ) : AsyncImageResource { + override val identifier = UUID().toString() + } + + data class Vector( + val image: ImageVector, + override val contentDescription: StringResource, + ) : AsyncImageResource { + override val identifier = UUID().toString() + } + + data class Drawable( + @DrawableRes val resId: Int, + override val contentDescription: StringResource, + ) : AsyncImageResource { + override val identifier = UUID().toString() + } + + companion object { + operator fun invoke(imageResource: ImageResource): AsyncImageResource = when (imageResource) { + is ImageResource.Vector -> AsyncImageResource.Vector( + image = imageResource.image, + contentDescription = imageResource.contentDescription + ) + is ImageResource.Drawable -> AsyncImageResource.Drawable( + resId = imageResource.resId, + contentDescription = imageResource.contentDescription + ) + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResourceComposable.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResourceComposable.kt new file mode 100644 index 000000000..b76dce6ad --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/AsyncImageResourceComposable.kt @@ -0,0 +1,114 @@ +package edu.stanford.spezi.core.design.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import coil.compose.AsyncImagePainter +import coil.compose.SubcomposeAsyncImage +import edu.stanford.spezi.core.design.theme.Colors +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.utils.extensions.imageResourceIdentifier + +/** + * Composable function to display an icon using an [ImageResource]. + */ +@Composable +fun AsyncImageResourceComposable( + imageResource: AsyncImageResource, + modifier: Modifier = Modifier, + loadingContent: @Composable BoxScope.() -> Unit = { + ShimmerEffectBox(modifier = Modifier.matchParentSize()) + }, + errorContent: @Composable BoxScope.(Throwable) -> Unit = { + Box(Modifier.matchParentSize()) { + Text("Error loading image", Modifier.align(Alignment.Center)) + } + }, + tint: Color = Colors.primary, +) { + val imageModifier = modifier.then(Modifier.imageResourceIdentifier(imageResource.identifier.toString())) + when (imageResource) { + is AsyncImageResource.Vector -> { + Icon( + imageVector = imageResource.image, + contentDescription = imageResource.contentDescription.text(), + tint = tint, + modifier = imageModifier + ) + } + + is AsyncImageResource.Drawable -> { + Icon( + painter = painterResource(id = imageResource.resId), + contentDescription = imageResource.contentDescription.text(), + tint = tint, + modifier = imageModifier, + ) + } + + is AsyncImageResource.Remote -> { + SubcomposeAsyncImage( + model = imageResource.url, + modifier = modifier, + contentDescription = imageResource.contentDescription.text(), + ) { + val state = painter.state + val painter = painter + if (state is AsyncImagePainter.State.Loading) { + loadingContent() + } + + if (state is AsyncImagePainter.State.Error) { + errorContent(state.result.throwable) + } + + if (state is AsyncImagePainter.State.Success) { + Box( + modifier = Modifier.matchParentSize(), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painter, + contentDescription = imageResource.contentDescription.text(), + contentScale = ContentScale.Crop, + ) + } + } + } + } + } +} + +@ThemePreviews +@Composable +private fun ImageResourceComposablePreview( + @PreviewParameter(AsyncImageResourceProvider::class) imageResource: ImageResource, +) { + SpeziTheme(isPreview = true) { + ImageResourceComposable( + imageResource = imageResource, + ) + } +} + +private class AsyncImageResourceProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + ImageResource.Vector(Icons.Default.ThumbUp, StringResource("Thumbs up")), + ImageResource.Drawable(android.R.drawable.ic_menu_camera, StringResource("Camera")), + ) +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResource.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResource.kt index e7c0408dd..8099dca74 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResource.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResource.kt @@ -3,6 +3,7 @@ package edu.stanford.spezi.core.design.component import androidx.annotation.DrawableRes import androidx.compose.ui.graphics.vector.ImageVector import edu.stanford.spezi.core.utils.UUID +import java.util.UUID import javax.annotation.concurrent.Immutable /** @@ -13,12 +14,21 @@ import javax.annotation.concurrent.Immutable * @see ImageResourceComposable */ @Immutable -sealed class ImageResource { - val identifier: String = UUID().toString() +sealed interface ImageResource { + val identifier: String + val contentDescription: StringResource - data class Vector(val image: ImageVector) : ImageResource() + data class Vector( + val image: ImageVector, + override val contentDescription: StringResource, + ) : ImageResource { + override val identifier = UUID().toString() + } - data class Drawable(@DrawableRes val resId: Int) : ImageResource() - - data class Remote(val url: String) : ImageResource() + data class Drawable( + @DrawableRes val resId: Int, + override val contentDescription: StringResource, + ) : ImageResource { + override val identifier = UUID().toString() + } } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResourceComposable.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResourceComposable.kt index a833a7f51..368ce462f 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResourceComposable.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/ImageResourceComposable.kt @@ -1,22 +1,14 @@ package edu.stanford.spezi.core.design.component -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import coil.compose.AsyncImagePainter -import coil.compose.SubcomposeAsyncImage import edu.stanford.spezi.core.design.theme.Colors import edu.stanford.spezi.core.design.theme.SpeziTheme import edu.stanford.spezi.core.design.theme.ThemePreviews @@ -28,16 +20,15 @@ import edu.stanford.spezi.core.utils.extensions.imageResourceIdentifier @Composable fun ImageResourceComposable( imageResource: ImageResource, - contentDescription: String, modifier: Modifier = Modifier, tint: Color = Colors.primary, ) { - val imageModifier = modifier.then(Modifier.imageResourceIdentifier(imageResource.identifier)) + val imageModifier = modifier.then(Modifier.imageResourceIdentifier(imageResource.identifier.toString())) when (imageResource) { is ImageResource.Vector -> { Icon( imageVector = imageResource.image, - contentDescription = contentDescription, + contentDescription = imageResource.contentDescription.text(), tint = tint, modifier = imageModifier ) @@ -46,45 +37,11 @@ fun ImageResourceComposable( is ImageResource.Drawable -> { Icon( painter = painterResource(id = imageResource.resId), - contentDescription = contentDescription, + contentDescription = imageResource.contentDescription.text(), tint = tint, modifier = imageModifier, ) } - - is ImageResource.Remote -> { - SubcomposeAsyncImage( - model = imageResource.url, - modifier = modifier, - contentDescription = contentDescription, - ) { - val state = painter.state - val painter = painter - if (state is AsyncImagePainter.State.Loading) { - ShimmerEffectBox(modifier = Modifier.matchParentSize()) - } - - if (state is AsyncImagePainter.State.Error) { - Box(Modifier.matchParentSize()) { - Text("Error loading image", Modifier.align(Alignment.Center)) - } - } - - if (state is AsyncImagePainter.State.Success) { - Box( - modifier = Modifier.matchParentSize(), - contentAlignment = Alignment.Center - ) { - Image( - modifier = Modifier.fillMaxSize(), - painter = painter, - contentDescription = contentDescription, - contentScale = ContentScale.Crop, - ) - } - } - } - } } } @@ -96,14 +53,13 @@ private fun ImageResourceComposablePreview( SpeziTheme(isPreview = true) { ImageResourceComposable( imageResource = imageResource, - contentDescription = "Icon" ) } } private class ImageResourceProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( - ImageResource.Vector(Icons.Default.ThumbUp), - ImageResource.Drawable(android.R.drawable.ic_menu_camera), + ImageResource.Vector(Icons.Default.ThumbUp, StringResource("Thumbs Up")), + ImageResource.Drawable(android.R.drawable.ic_menu_camera, StringResource("Camera")), ) } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/markdown/MarkdownElement.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/markdown/MarkdownElement.kt index e8e8d9f4f..f88d0b038 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/markdown/MarkdownElement.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/markdown/MarkdownElement.kt @@ -1,8 +1,8 @@ package edu.stanford.spezi.core.design.component.markdown -sealed class MarkdownElement { - data class Heading(val level: Int, val text: String) : MarkdownElement() - data class Paragraph(val text: String) : MarkdownElement() - data class Bold(val text: String) : MarkdownElement() - data class ListItem(val text: String) : MarkdownElement() +sealed interface MarkdownElement { + data class Heading(val level: Int, val text: String) : MarkdownElement + data class Paragraph(val text: String) : MarkdownElement + data class Bold(val text: String) : MarkdownElement + data class ListItem(val text: String) : MarkdownElement } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/PersonNameComponents.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/PersonNameComponents.kt new file mode 100644 index 000000000..94e55e2a9 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/PersonNameComponents.kt @@ -0,0 +1,64 @@ +package edu.stanford.spezi.core.design.views.personalinfo + +data class PersonNameComponents( + val namePrefix: String? = null, + val givenName: String? = null, + val middleName: String? = null, + val familyName: String? = null, + val nameSuffix: String? = null, + val nickname: String? = null, +) { + constructor(builder: Builder) : this( + namePrefix = builder.namePrefix, + givenName = builder.givenName, + middleName = builder.middleName, + familyName = builder.familyName, + nameSuffix = builder.nameSuffix, + nickname = builder.nickname, + ) + + enum class FormatStyle { + // TODO: Styles SHORT and MEDIUM missing + ABBREVIATED, LONG + } + + fun createBuilder() = Builder(this) + + fun formatted(style: FormatStyle = FormatStyle.LONG): String { + return when (style) { + FormatStyle.LONG -> listOfNotNull( + namePrefix, + givenName, + nickname?.let { "\"$it\"" }, + middleName, + familyName, + nameSuffix + ).joinToString(" ") + FormatStyle.ABBREVIATED -> listOfNotNull( + givenName, + familyName, + ).joinToString("") + .filter { it.isUpperCase() } + } + } + + class Builder( + var namePrefix: String? = null, + var givenName: String? = null, + var middleName: String? = null, + var familyName: String? = null, + var nameSuffix: String? = null, + var nickname: String? = null, + ) { + constructor(components: PersonNameComponents) : this( + namePrefix = components.namePrefix, + givenName = components.givenName, + middleName = components.middleName, + familyName = components.familyName, + nameSuffix = components.nameSuffix, + nickname = components.nickname, + ) + + fun build() = PersonNameComponents(this) + } +} 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 new file mode 100644 index 000000000..60696d207 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/UserProfileComposable.kt @@ -0,0 +1,126 @@ +package edu.stanford.spezi.core.design.views.personalinfo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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.component.StringResource +import edu.stanford.spezi.core.design.theme.Colors +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.theme.lighten +import edu.stanford.spezi.core.logging.SpeziLogger +import kotlin.math.min + +@Composable +fun UserProfileComposable( + name: PersonNameComponents, + modifier: Modifier = Modifier, + imageLoader: suspend () -> ImageResource? = { null }, +) { + var size by remember { mutableStateOf(IntSize.Zero) } + var loadedImage by remember(imageLoader) { mutableStateOf(null) } + + LaunchedEffect(Unit) { + loadedImage = runCatching { imageLoader() } + .onFailure { SpeziLogger.e(it) { "Failed to load image" } } + .getOrNull() + } + + val formattedName = remember(name) { + name.formatted(PersonNameComponents.FormatStyle.ABBREVIATED) + } + + Box( + modifier + .onSizeChanged { size = it } + .aspectRatio(1f)) { + val sideLength = min(size.height, size.width).dp + Box(modifier.size(sideLength, sideLength), contentAlignment = Alignment.Center) { + loadedImage?.let { + ImageResourceComposable( + it, + Modifier + .fillMaxSize() + .clip(CircleShape) + .background(Colors.background, CircleShape) + ) + } ?: Box( + Modifier + .background(Colors.secondary, CircleShape) + .fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + formattedName, + fontSize = (sideLength.value * 0.2).sp, + color = Colors.secondary.lighten(), + ) + } + } + } +} + +private typealias UserProfilePreviewData = Pair ImageResource?> +private class UserProfileProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + Pair( + PersonNameComponents( + givenName = "Paul", + familyName = "Schmiedmayer", + ) + ) { null }, + Pair( + PersonNameComponents( + namePrefix = "Prof.", + givenName = "Oliver", + middleName = "Oppers", + familyName = "Aalami" + ) + ) { null }, + Pair( + PersonNameComponents( + givenName = "Vishnu", + familyName = "Ravi", + ) + ) { + ImageResource.Vector( + Icons.Default.Person, + StringResource("Person") + ) + }, + ) +} + +@ThemePreviews +@Composable +private fun UserProfileComposablePreview( + @PreviewParameter(UserProfileProvider::class) profileData: UserProfilePreviewData, +) { + SpeziTheme(isPreview = true) { + UserProfileComposable( + name = profileData.first, + imageLoader = profileData.second, + ) + } +} 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 new file mode 100644 index 000000000..3f614b2f5 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/fields/NameFieldRow.kt @@ -0,0 +1,83 @@ +package edu.stanford.spezi.core.design.views.personalinfo.fields + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.personalinfo.PersonNameComponents +import edu.stanford.spezi.core.design.views.views.layout.DescriptionGridRow +import kotlin.reflect.KMutableProperty1 + +@Composable +fun NameFieldRow( + description: String, + builder: PersonNameComponents.Builder, + property: KMutableProperty1, + modifier: Modifier = Modifier, + label: @Composable () -> Unit, +) { + NameFieldRow( + builder = builder, + property = property, + description = { Text(description) }, + modifier = modifier, + label = label + ) +} + +@Composable +fun NameFieldRow( + builder: PersonNameComponents.Builder, + property: KMutableProperty1, + description: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + label: @Composable () -> Unit, +) { + DescriptionGridRow( + description = description, + modifier = modifier, + content = { + NameTextField( + builder = builder, + property = property, + label = label, + ) + } + ) +} + +@ThemePreviews +@Composable +private fun NameFieldRowPreview() { + val nameBuilder = remember { PersonNameComponents.Builder() } + + SpeziTheme(isPreview = true) { + Column { + NameFieldRow( + nameBuilder, + PersonNameComponents.Builder::givenName, + description = { Text("First") } + ) { + Text("enter first name") + } + + HorizontalDivider(Modifier.padding(vertical = 15.dp)) + + // Last Name Field + NameFieldRow( + nameBuilder, + PersonNameComponents.Builder::familyName, + description = { Text("Last") } + ) { + Text("enter last name") + } + } + } +} 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 new file mode 100644 index 000000000..e9d75c02a --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/personalinfo/fields/NameTextField.kt @@ -0,0 +1,76 @@ +package edu.stanford.spezi.core.design.views.personalinfo.fields + +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.personalinfo.PersonNameComponents +import edu.stanford.spezi.core.utils.extensions.testIdentifier +import kotlin.reflect.KMutableProperty1 + +enum class NameTextFieldTestIdentifier { + TEXT_FIELD, +} + +@Composable +fun NameTextField( + label: String, + builder: PersonNameComponents.Builder, + property: KMutableProperty1, + modifier: Modifier = Modifier, +) { + NameTextField( + builder = builder, + property = property, + modifier = modifier, + ) { + Text(label) + } +} + +// TODO: We got rid of "prompt" property here +@Composable +fun NameTextField( + builder: PersonNameComponents.Builder, + property: KMutableProperty1, + modifier: Modifier = Modifier, + label: @Composable () -> Unit, +) { + val textState = remember(builder) { + mutableStateOf(property.get(builder) ?: "") + } + + // TODO: Figure out which other options to set on the keyboard for names + TextField( + textState.value, + onValueChange = { + property.set(builder, it.ifBlank { null }) + textState.value = it + }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + ), + placeholder = label, + modifier = modifier + .fillMaxWidth() + .testIdentifier(NameTextFieldTestIdentifier.TEXT_FIELD, property.name) + ) +} + +@ThemePreviews +@Composable +private fun NameTextFieldPreview() { + val name = remember { PersonNameComponents.Builder() } + + SpeziTheme(isPreview = true) { + NameTextField(name, PersonNameComponents.Builder::givenName) { + Text("Enter first name") + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/CascadingValidationEffect.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/CascadingValidationEffect.kt new file mode 100644 index 000000000..e1676ff23 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/CascadingValidationEffect.kt @@ -0,0 +1,5 @@ +package edu.stanford.spezi.core.design.views.validation + +enum class CascadingValidationEffect { + CONTINUE, INTERCEPT +} 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 new file mode 100644 index 000000000..779af4367 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationEngine.kt @@ -0,0 +1,124 @@ +package edu.stanford.spezi.core.design.views.validation + +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 edu.stanford.spezi.core.logging.speziLogger +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.EnumSet +import kotlin.time.Duration + +internal typealias ValidationEngineConfiguration = EnumSet + +interface ValidationEngine { + enum class ConfigurationOption { + HIDE_FAILED_VALIDATION_ON_EMPTY_SUBMIT, + CONSIDER_NO_INPUT_AS_VALID, + } + + 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 val logger by speziLogger() + + private var validationResultsState = mutableStateOf(emptyList()) + + override val validationResults get() = validationResultsState.value + + private var computedInputValid: Boolean? = null + + override val inputValid: Boolean get() = + computedInputValid ?: configuration.contains(ValidationEngine.ConfigurationOption.CONSIDER_NO_INPUT_AS_VALID) + + private var source: Source? = null + private var inputWasEmpty = true + + override val isDisplayingValidationErrors: Boolean get() { + val gotResults = validationResults.isNotEmpty() + + if (configuration.contains(ValidationEngine.ConfigurationOption.HIDE_FAILED_VALIDATION_ON_EMPTY_SUBMIT)) { + return gotResults && (source == Source.MANUAL || !inputWasEmpty) + } + + return gotResults + } + + override val displayedValidationResults: List get() = + if (isDisplayingValidationErrors) validationResults else emptyList() + + private var debounceJob: Job? = null + + private fun computeFailedValidations(input: String): List { + val results = mutableListOf() + + @Suppress("detekt:LoopWithTooManyJumpStatements") + for (rule in rules) { + rule.validate(input)?.let { result -> + results.add(result) + logger.w { "Validation for input $input failed with reason: ${result.message}" } + } ?: continue + if (rule.effect == CascadingValidationEffect.INTERCEPT) break + } + + return results + } + + private fun computeValidation(input: String, source: Source) { + this.source = source + this.inputWasEmpty = input.isEmpty() + + this.validationResultsState.value = computeFailedValidations(input) + this.computedInputValid = validationResults.isEmpty() + } + + override fun submit(input: String, debounce: Boolean) { + if (!debounce || computedInputValid == false) { + computeValidation(input, Source.SUBMIT) + } else { + this.debounce { + this.computeValidation(input, Source.SUBMIT) + } + } + } + + override fun runValidation(input: String) { + computeValidation(input, Source.MANUAL) + } + + @OptIn(DelicateCoroutinesApi::class) + private fun debounce(task: () -> Unit) { + debounceJob?.cancel() + // TODO: Think about whether to not use GlobalScope here + debounceJob = GlobalScope.launch { + delay(debounceDuration) + + if (!isActive) return@launch + + task() + debounceJob = null + } + } +} 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 new file mode 100644 index 000000000..c00f6b9f2 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationModifier.kt @@ -0,0 +1,86 @@ +package edu.stanford.spezi.core.design.views.validation + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.views.validation.configuration.DEFAULT_VALIDATION_DEBOUNCE_DURATION +import edu.stanford.spezi.core.design.views.validation.configuration.LocalValidationEngine +import edu.stanford.spezi.core.design.views.validation.configuration.LocalValidationEngineConfiguration +import edu.stanford.spezi.core.design.views.validation.state.CapturedValidationState +import edu.stanford.spezi.core.design.views.validation.state.LocalCapturedValidationStateEntries +import kotlin.time.Duration + +@Composable +fun Validate( + predicate: Boolean, + message: StringResource, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val rule = remember { + ValidationRule( + rule = { it.isEmpty() }, + message = message + ) + } + Validate( + input = if (predicate) "" else "FALSE", + rules = listOf(rule), + modifier = modifier, + content = content + ) +} + +@Composable +fun Validate( + input: String, + rules: List, + modifier: Modifier = Modifier, + validationDebounce: Duration = DEFAULT_VALIDATION_DEBOUNCE_DURATION, + content: @Composable () -> Unit, +) { + val validationEngineConfiguration = LocalValidationEngineConfiguration.current + val engine = remember { + ValidationEngineImpl( + rules, + validationDebounce, + validationEngineConfiguration + ) + } + + var isFirstInput by remember { mutableStateOf(true) } + LaunchedEffect(input) { + if (isFirstInput) { + isFirstInput = false + } else { + engine.submit(input, debounce = true) + } + } + + LaunchedEffect(validationDebounce) { + engine.debounceDuration = validationDebounce + } + + LaunchedEffect(validationEngineConfiguration) { + engine.configuration = validationEngineConfiguration + } + + val hasFocus = remember { mutableStateOf(false) } + LocalCapturedValidationStateEntries.current + .add(CapturedValidationState(engine, input, hasFocus)) + + CompositionLocalProvider(LocalValidationEngine provides engine) { + Box(modifier) { + content() + } + // TODO: onSubmit missing + // TODO: focused missing + } +} 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 new file mode 100644 index 000000000..28e6655c7 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRule.kt @@ -0,0 +1,36 @@ +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(), + val rule: (String) -> Boolean, + val message: StringResource, + val effect: CascadingValidationEffect = CascadingValidationEffect.CONTINUE, +) { + constructor(regex: Regex, message: StringResource) : this( + rule = { regex.matchEntire(it) != null }, + message = message, + ) + + constructor(copy: ValidationRule, message: StringResource) : this( + rule = copy.rule, + message = message, + ) + + val intercepting: ValidationRule + get() = ValidationRule(id, rule, message, CascadingValidationEffect.INTERCEPT) + + override fun equals(other: Any?): Boolean = + id == (other as? ValidationRule)?.id + + fun validate(input: String): FailedValidationResult? = + if (rule(input)) null else FailedValidationResult(this) + + override fun hashCode(): Int = id.hashCode() + + companion object +} 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 new file mode 100644 index 000000000..8496893d6 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/ValidationRuleDefaults.kt @@ -0,0 +1,46 @@ +package edu.stanford.spezi.core.design.views.validation + +import edu.stanford.spezi.core.design.component.StringResource +import java.nio.charset.StandardCharsets + +val ValidationRule.Companion.nonEmpty: ValidationRule + get() = ValidationRule( + regex = Regex(".*\\S+.*"), + message = StringResource("This field cannot be empty.") + ) + +val ValidationRule.Companion.unicodeLettersOnly: ValidationRule + get() = ValidationRule( + rule = { string -> string.all { it.isLetter() } }, + message = StringResource("VALIDATION_RULE_UNICODE_LETTERS") + ) + +val ValidationRule.Companion.asciiLettersOnly: ValidationRule + get() = ValidationRule( + rule = { string -> StandardCharsets.US_ASCII.newEncoder().canEncode(string) }, + message = StringResource("VALIDATION_RULE_UNICODE_LETTERS_ASCII") + ) + +val ValidationRule.Companion.minimalEmail: ValidationRule + get() = ValidationRule( + regex = Regex(".*@.+"), + message = StringResource("VALIDATION_RULE_MINIMAL_EMAIL") + ) + +val ValidationRule.Companion.minimalPassword: ValidationRule + get() = ValidationRule( + regex = Regex(".{8,}"), + message = StringResource("Your password must be at least 8 characters long.") + ) + +val ValidationRule.Companion.mediumPassword: ValidationRule + get() = ValidationRule( + regex = Regex(".{10,}"), + message = StringResource("Your password must be at least 10 characters long.") + ) + +val ValidationRule.Companion.strongPassword: ValidationRule + get() = ValidationRule( + regex = Regex(".{12,}"), + message = StringResource("Your password must be at least 12 characters long.") + ) diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngine.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngine.kt new file mode 100644 index 000000000..134d18aae --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngine.kt @@ -0,0 +1,6 @@ +package edu.stanford.spezi.core.design.views.validation.configuration + +import androidx.compose.runtime.compositionLocalOf +import edu.stanford.spezi.core.design.views.validation.ValidationEngine + +internal val LocalValidationEngine = compositionLocalOf { null } diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngineConfiguration.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngineConfiguration.kt new file mode 100644 index 000000000..b75558ad0 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/configuration/ValidationEngineConfiguration.kt @@ -0,0 +1,12 @@ +package edu.stanford.spezi.core.design.views.validation.configuration + +import androidx.compose.runtime.compositionLocalOf +import edu.stanford.spezi.core.design.views.validation.ValidationEngine +import edu.stanford.spezi.core.design.views.validation.ValidationEngineConfiguration +import kotlin.time.Duration.Companion.milliseconds + +internal val DEFAULT_VALIDATION_DEBOUNCE_DURATION = 150.milliseconds + +val LocalValidationEngineConfiguration = compositionLocalOf { + ValidationEngineConfiguration.noneOf(ValidationEngine.ConfigurationOption::class.java) +} 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 new file mode 100644 index 000000000..2b20999df --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationState.kt @@ -0,0 +1,27 @@ +package edu.stanford.spezi.core.design.views.validation.state + +import androidx.compose.runtime.MutableState +import edu.stanford.spezi.core.design.views.validation.ValidationEngine + +data class CapturedValidationState internal constructor( + private val engine: ValidationEngine, + private val input: String, + private val isFocused: MutableState, +) : ValidationEngine by engine { + internal fun moveFocus() { + isFocused.value = true + } + + fun runValidation() { + engine.runValidation(input) + } + + override fun hashCode(): Int { + return super.hashCode() + } + + override fun equals(other: Any?): Boolean = + (other as? CapturedValidationState)?.let { + it.engine === engine && it.input == input + } ?: false +} 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 new file mode 100644 index 000000000..50d5e1583 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/CapturedValidationStateEntries.kt @@ -0,0 +1,15 @@ +package edu.stanford.spezi.core.design.views.validation.state + +import androidx.compose.runtime.compositionLocalOf + +internal val LocalCapturedValidationStateEntries = compositionLocalOf { CapturedValidationStateEntries() } + +internal data class CapturedValidationStateEntries( + private var mutableEntries: MutableList = mutableListOf(), +) { + val entries: List get() = mutableEntries + + fun add(state: CapturedValidationState) { + mutableEntries.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 new file mode 100644 index 000000000..3ed8afa68 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/FailedValidationResult.kt @@ -0,0 +1,18 @@ +package edu.stanford.spezi.core.design.views.validation.state + +import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.views.validation.ValidationRule +import java.util.UUID + +data class FailedValidationResult( + val id: UUID, + val message: StringResource, +) { + constructor(rule: ValidationRule) : this( + id = rule.id, + message = 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 new file mode 100644 index 000000000..94ce4e850 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ReceiveValidation.kt @@ -0,0 +1,22 @@ +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, +) { + // This is not remembered on purpose, since we are re-evaluating the validation here. + val entries = CapturedValidationStateEntries() + CompositionLocalProvider(LocalCapturedValidationStateEntries provides entries) { + content() + + LaunchedEffect(entries.entries) { + state.value = ValidationContext(entries.entries) + } + } +} 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 new file mode 100644 index 000000000..fea465d49 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/state/ValidationContext.kt @@ -0,0 +1,49 @@ +package edu.stanford.spezi.core.design.views.validation.state + +data class ValidationContext internal constructor( + private val entries: List = emptyList(), +) : Iterable { + val allInputValid: Boolean get() = + entries.all { it.inputValid } + + val allValidationResults: List get() = + entries.fold(emptyList()) { acc, entry -> acc + entry.validationResults } + + val allDisplayedValidationResults: List get() = + entries.fold(emptyList()) { acc, entry -> acc + entry.displayedValidationResults } + + val isDisplayingValidationErrors: Boolean get() = + entries.any { it.isDisplayingValidationErrors } + + override fun iterator(): Iterator = entries.iterator() + + val isEmpty: Boolean + get() = entries.isEmpty() + + private fun collectFailedValidations(): List { + return mapNotNull { state -> + state.runValidation() + + if (!state.inputValid) state else null + } + } + + fun validateHierarchy(switchFocus: Boolean = true): Boolean { + val failedFields = collectFailedValidations() + + return failedFields.firstOrNull()?.let { + if (switchFocus) { + it.moveFocus() + } + + 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 new file mode 100644 index 000000000..d972b1ebb --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/ValidationResultsComposable.kt @@ -0,0 +1,44 @@ +package edu.stanford.spezi.core.design.views.validation.views + +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.SpeziTheme +import edu.stanford.spezi.core.design.theme.TextStyles +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.validation.ValidationRule +import edu.stanford.spezi.core.design.views.validation.mediumPassword +import edu.stanford.spezi.core.design.views.validation.nonEmpty +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, + ) + } + } +} + +@ThemePreviews +@Composable +private fun ValidationResultsComposablePreview() { + SpeziTheme(isPreview = true) { + ValidationResultsComposable( + listOf( + FailedValidationResult(ValidationRule.nonEmpty), + FailedValidationResult(ValidationRule.mediumPassword), + ) + ) + } +} 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 new file mode 100644 index 000000000..859b3b2f6 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/validation/views/VerifiableTextField.kt @@ -0,0 +1,148 @@ +package edu.stanford.spezi.core.design.views.validation.views + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +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.configuration.LocalValidationEngine +import edu.stanford.spezi.core.design.views.validation.nonEmpty + +enum class TextFieldType { + TEXT, SECURE +} + +@Composable +fun VerifiableTextField( + label: StringResource, + state: MutableState, + modifier: Modifier = Modifier, + type: TextFieldType = TextFieldType.TEXT, + disableAutocorrection: Boolean = false, + footer: @Composable () -> Unit = {}, +) { + VerifiableTextField( + label = label, + value = state.value, + onValueChanged = { state.value = it }, + modifier = modifier, + type = type, + disableAutocorrection = disableAutocorrection, + footer = footer, + ) +} + +@Composable +fun VerifiableTextField( + label: StringResource, + value: String, + onValueChanged: (String) -> Unit, + modifier: Modifier = Modifier, + type: TextFieldType = TextFieldType.TEXT, + disableAutocorrection: Boolean = false, + footer: @Composable () -> Unit = {}, +) { + VerifiableTextField( + value = value, + onValueChanged = onValueChanged, + modifier = modifier, + type = type, + disableAutocorrection = disableAutocorrection, + footer = footer, + label = { Text(label.text()) }, + ) +} + +@Composable +fun VerifiableTextField( + state: MutableState, + modifier: Modifier = Modifier, + type: TextFieldType = TextFieldType.TEXT, + disableAutocorrection: Boolean = false, + footer: @Composable () -> Unit = {}, + label: @Composable () -> Unit, +) { + VerifiableTextField( + value = state.value, + onValueChanged = { state.value = it }, + modifier = modifier, + type = type, + disableAutocorrection = disableAutocorrection, + footer = footer, + label = label + ) +} + +@Composable +fun VerifiableTextField( + value: String, + onValueChanged: (String) -> Unit, + modifier: Modifier = Modifier, + type: TextFieldType = TextFieldType.TEXT, + disableAutocorrection: Boolean = false, + footer: @Composable () -> Unit = {}, + label: @Composable () -> Unit, +) { + val validationEngine = LocalValidationEngine.current + val isSecure = remember(type) { type == TextFieldType.SECURE } + + // TODO: Check equality with iOS + TextField( + value = value, + onValueChange = onValueChanged, + label = label, + keyboardActions = KeyboardActions( + onDone = { + validationEngine?.submit(value) + }, + ), + keyboardOptions = KeyboardOptions( + keyboardType = if (isSecure) KeyboardType.Password else KeyboardType.Text, + autoCorrect = !disableAutocorrection + ), + supportingText = { + Row(Modifier.padding(vertical = Spacings.small)) { + ValidationResultsComposable(validationEngine?.displayedValidationResults ?: emptyList()) + Spacer(Modifier.fillMaxWidth()) + footer() + } + }, + isError = validationEngine?.isDisplayingValidationErrors ?: true, + visualTransformation = if (isSecure) PasswordVisualTransformation() else VisualTransformation.None, + modifier = modifier.fillMaxWidth(), + ) +} + +@ThemePreviews +@Composable +private fun VerifiableTextFieldPreview() { + val text = remember { mutableStateOf("") } + + SpeziTheme(isPreview = true) { + Validate(text.value, rules = listOf(ValidationRule.nonEmpty)) { + VerifiableTextField( + text, + footer = { Text("Some Hint") }, + ) { + Text("Password Text") + } + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/layout/DescriptionGridRow.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/layout/DescriptionGridRow.kt new file mode 100644 index 000000000..1b9d458b2 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/layout/DescriptionGridRow.kt @@ -0,0 +1,77 @@ +package edu.stanford.spezi.core.design.views.views.layout + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews + +@Composable +fun DescriptionGridRow( + description: @Composable BoxScope.() -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = Spacings.small), + horizontalArrangement = Arrangement.spacedBy(Spacings.medium), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .alignByBaseline() + ) { + description() + } + + Box( + modifier = Modifier + .alignByBaseline() + .fillMaxWidth() + ) { + content() + } + } +} + +@ThemePreviews +@Composable +private fun DescriptionGridRowPreviews() { + SpeziTheme(isPreview = true) { + Column { + DescriptionGridRow(description = { + Text("Description") + }) { + Text("Content") + } + + HorizontalDivider() + + DescriptionGridRow(description = { + Text("Description") + }) { + Text("Content") + } + + HorizontalDivider() + + DescriptionGridRow(description = { + Text("Description") + }) { + Text("Content") + } + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/OperationState.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/OperationState.kt new file mode 100644 index 000000000..e252a1615 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/OperationState.kt @@ -0,0 +1,5 @@ +package edu.stanford.spezi.core.design.views.views.model + +interface OperationState { + val representation: ViewState +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/ViewState.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/ViewState.kt new file mode 100644 index 000000000..c5c798033 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/model/ViewState.kt @@ -0,0 +1,17 @@ +package edu.stanford.spezi.core.design.views.views.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import edu.stanford.spezi.core.design.component.StringResource + +sealed interface ViewState { + data object Idle : ViewState + data object Processing : ViewState + data class Error(val throwable: Throwable?) : ViewState { + val errorTitle: String + @Composable @ReadOnlyComposable get() = StringResource("Error").text() + + val errorDescription: String + @Composable @ReadOnlyComposable get() = throwable?.localizedMessage ?: "" + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/ProcessingOverlay.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/ProcessingOverlay.kt new file mode 100644 index 000000000..1bae3f4b5 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/ProcessingOverlay.kt @@ -0,0 +1,70 @@ +package edu.stanford.spezi.core.design.views.views.views.button + +import androidx.compose.animation.core.animate +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.views.model.ViewState + +@Composable +fun ProcessingOverlay( + viewState: ViewState, + modifier: Modifier = Modifier, + processingContent: @Composable BoxScope.() -> Unit = { CircularProgressIndicator() }, + content: @Composable BoxScope.() -> Unit, +) { + ProcessingOverlay( + isProcessing = viewState == ViewState.Processing, + modifier = modifier, + processingContent = processingContent, + content = content, + ) +} + +@Composable +fun ProcessingOverlay( + isProcessing: Boolean, + modifier: Modifier = Modifier, + processingContent: @Composable BoxScope.() -> Unit = { CircularProgressIndicator() }, + content: @Composable BoxScope.() -> Unit, +) { + val alpha = remember { mutableFloatStateOf(1f) } + LaunchedEffect(isProcessing) { + val newValue = if (isProcessing) 0f else 1f + animate(1f - newValue, newValue) { value, _ -> + alpha.floatValue = value + } + } + Box(contentAlignment = Alignment.Center, modifier = modifier) { + Box(Modifier.alpha(alpha.floatValue)) { + content() + } + + if (isProcessing) { + Box(Modifier.matchParentSize(), contentAlignment = Alignment.Center) { + processingContent() + } + } + } +} + +@ThemePreviews +@Composable +private fun ProcessingOverlayPreview() { + SpeziTheme(isPreview = true) { + ProcessingOverlay(true) { + SuspendButton("Do something") { + println("Did something") + } + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/SuspendButton.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/SuspendButton.kt new file mode 100644 index 000000000..e60792bf5 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/button/SuspendButton.kt @@ -0,0 +1,93 @@ +package edu.stanford.spezi.core.design.views.views.views.button + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import edu.stanford.spezi.core.design.component.Button +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.views.model.ViewState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private enum class SuspendButtonState { + IDLE, DISABLED, DISABLED_AND_PROCESSING +} + +@Composable +fun SuspendButton( + title: String, + state: MutableState = remember { mutableStateOf(ViewState.Idle) }, + action: suspend () -> Unit, +) { + SuspendButton(state = state, action = action) { + Text(title) + } +} + +@Composable +fun SuspendButton( + processingDebounceDuration: Duration = 150.milliseconds, + state: MutableState = remember { mutableStateOf(ViewState.Idle) }, + action: suspend () -> Unit, + label: @Composable () -> Unit, +) { + val buttonState = remember { mutableStateOf(SuspendButtonState.IDLE) } + val coroutineScope = rememberCoroutineScope() + val debounceIsCancelled = remember { mutableStateOf(false) } + val externallyProcessing = buttonState.value == SuspendButtonState.IDLE && state.value == ViewState.Processing + + Button( + enabled = buttonState.value == SuspendButtonState.IDLE && !externallyProcessing, + onClick = { + if (state.value == ViewState.Processing) return@Button + buttonState.value = SuspendButtonState.DISABLED + + coroutineScope.launch { + delay(processingDebounceDuration) + + if (debounceIsCancelled.value) return@launch + buttonState.value = SuspendButtonState.DISABLED_AND_PROCESSING + } + + state.value = ViewState.Processing + coroutineScope.launch { + runCatching { + action() + debounceIsCancelled.value = true + + if (state.value != ViewState.Idle) { + state.value = ViewState.Idle + } + }.onFailure { + debounceIsCancelled.value = true + state.value = ViewState.Error(it) + } + + buttonState.value = SuspendButtonState.IDLE + } + }, + ) { + ProcessingOverlay( + isProcessing = buttonState.value == SuspendButtonState.DISABLED_AND_PROCESSING || externallyProcessing + ) { + label() + } + } +} + +@ThemePreviews +@Composable +private fun SuspendButtonPreview() { + val state = remember { mutableStateOf(ViewState.Idle) } + SpeziTheme(isPreview = true) { + SuspendButton("Test Button", state) { + throw NotImplementedError() + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/text/Markdown.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/text/Markdown.kt new file mode 100644 index 000000000..8f1678e8b --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/views/text/Markdown.kt @@ -0,0 +1,92 @@ +package edu.stanford.spezi.core.design.views.views.views.text + +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import edu.stanford.spezi.core.design.component.markdown.MarkdownComponent +import edu.stanford.spezi.core.design.component.markdown.MarkdownElement +import edu.stanford.spezi.core.design.component.markdown.MarkdownParser +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.views.model.ViewState +import java.nio.charset.StandardCharsets + +@Composable +fun MarkdownBytes( + bytes: ByteArray, + state: MutableState = remember { mutableStateOf(ViewState.Idle) }, +) { + MarkdownBytes( + bytes = { bytes }, + state = state, + ) +} + +@Composable +fun MarkdownBytes( + bytes: suspend () -> ByteArray, + state: MutableState = remember { mutableStateOf(ViewState.Idle) }, +) { + MarkdownString( + string = { bytes().toString(StandardCharsets.UTF_8) }, + state = state, + ) +} + +@Composable +fun MarkdownString( + string: String, + state: MutableState = remember { mutableStateOf(ViewState.Idle) }, +) { + MarkdownString( + string = { string }, + state = state, + ) +} + +@Composable +fun MarkdownString( + string: suspend () -> String, + state: MutableState = remember { mutableStateOf(ViewState.Idle) }, +) { + Markdown( + build = { MarkdownParser().parse(string()) }, + state = state, + ) +} + +@Composable +fun Markdown( + build: suspend () -> List, + state: MutableState = remember { mutableStateOf(ViewState.Idle) }, +) { + var markdownContent by remember { mutableStateOf?>(null) } + + @Suppress("detekt:TooGenericExceptionCaught") + LaunchedEffect(Unit) { + state.value = ViewState.Processing + try { + markdownContent = build() + state.value = ViewState.Idle + } catch (throwable: Throwable) { + state.value = ViewState.Error(throwable) + } + } + + markdownContent?.let { + MarkdownComponent(it) + } ?: CircularProgressIndicator() +} + +@ThemePreviews +@Composable +private fun MarkdownPreview() { + SpeziTheme(isPreview = true) { + MarkdownString("This is a markdown **example**!") + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/OperationStateAlert.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/OperationStateAlert.kt new file mode 100644 index 000000000..30d1e6275 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/OperationStateAlert.kt @@ -0,0 +1,37 @@ +package edu.stanford.spezi.core.design.views.views.viewstate + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.views.views.model.OperationState +import edu.stanford.spezi.core.design.views.views.model.ViewState + +@Composable +fun OperationStateAlert( + state: MutableState, + modifier: Modifier = Modifier, + onClose: () -> Unit = {}, +) { + val viewState = mapOperationStateToViewState(state.value) + ViewStateAlert( + state = viewState.value, + modifier = modifier, + onClose = { + viewState.value = ViewState.Idle + onClose() + } + ) +} + +@Composable +fun OperationStateAlert( + state: State, + modifier: Modifier = Modifier, + onClose: () -> Unit, +) { + ViewStateAlert( + state = state.representation, + modifier = modifier, + onClose = onClose + ) +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateAlert.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateAlert.kt new file mode 100644 index 000000000..0c8f2d44c --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateAlert.kt @@ -0,0 +1,61 @@ +package edu.stanford.spezi.core.design.views.views.viewstate + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.component.StringResource +import edu.stanford.spezi.core.design.theme.SpeziTheme +import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.views.model.ViewState + +@Composable +fun ViewStateAlert( + state: MutableState, + modifier: Modifier = Modifier, +) { + ViewStateAlert( + state = state.value, + onClose = { state.value = ViewState.Idle }, + modifier = modifier + ) +} + +@Composable +fun ViewStateAlert( + state: ViewState, + modifier: Modifier = Modifier, + onClose: () -> Unit, +) { + if (state is ViewState.Error) { + AlertDialog( + modifier = modifier, + title = { + Text(text = state.errorTitle) + }, + text = { + Text(text = state.errorDescription) + }, + onDismissRequest = onClose, + confirmButton = { + TextButton(onClick = onClose) { + Text(StringResource("OK").text()) + } + } + ) + } +} + +@ThemePreviews +@Composable +private fun ViewStateAlertPreview() { + val state = remember { mutableStateOf(ViewState.Error(NotImplementedError())) } + + SpeziTheme(isPreview = true) { + ViewStateAlert(state) + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateMapper.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateMapper.kt new file mode 100644 index 000000000..a2b3b3954 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/views/views/viewstate/ViewStateMapper.kt @@ -0,0 +1,18 @@ +package edu.stanford.spezi.core.design.views.views.viewstate + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import edu.stanford.spezi.core.design.views.views.model.OperationState +import edu.stanford.spezi.core.design.views.views.model.ViewState + +@Composable +fun mapOperationStateToViewState(state: State): MutableState { + val result = remember { mutableStateOf(state.representation) } + LaunchedEffect(state) { + result.value = state.representation + } + return result +} diff --git a/core/design/src/test/kotlin/edu/stanford/spezi/core/design/SpeziValidationTest.kt b/core/design/src/test/kotlin/edu/stanford/spezi/core/design/SpeziValidationTest.kt new file mode 100644 index 000000000..fd7c4203d --- /dev/null +++ b/core/design/src/test/kotlin/edu/stanford/spezi/core/design/SpeziValidationTest.kt @@ -0,0 +1,32 @@ +package edu.stanford.spezi.core.design + +import com.google.common.truth.Truth.assertThat +import edu.stanford.spezi.core.design.views.validation.ValidationEngineImpl +import edu.stanford.spezi.core.design.views.validation.ValidationRule +import edu.stanford.spezi.core.design.views.validation.nonEmpty +import org.junit.Test + +class SpeziValidationTest { + + @Test + fun testValidationDebounce() { + val engine = ValidationEngineImpl(rules = listOf(ValidationRule.nonEmpty)) + + engine.submit("Valid") + assertThat(engine.inputValid).isTrue() + assertThat(engine.validationResults).isEmpty() + + engine.submit("", debounce = true) + assertThat(engine.inputValid).isTrue() + assertThat(engine.validationResults).isEmpty() + + Thread.sleep(1_000) + + assertThat(engine.inputValid).isFalse() + assertThat(engine.validationResults).hasSize(1) + + engine.submit("Valid", debounce = true) + assertThat(engine.inputValid).isTrue() // valid state is reported instantly + assertThat(engine.validationResults).isEmpty() + } +} diff --git a/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/ContactFactory.kt b/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/ContactFactory.kt index 1a32dc9a1..0a1d4d31d 100644 --- a/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/ContactFactory.kt +++ b/modules/contact/src/androidTest/kotlin/edu/stanford/spezi/modules/contact/ContactFactory.kt @@ -5,9 +5,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBox 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.modules.contact.model.Contact import edu.stanford.spezi.modules.contact.model.ContactOption -import edu.stanford.spezi.modules.contact.model.PersonNameComponents import edu.stanford.spezi.modules.contact.model.call import edu.stanford.spezi.modules.contact.model.email import edu.stanford.spezi.modules.contact.model.text @@ -16,8 +16,11 @@ import java.util.Locale object ContactFactory { val leland = Contact( - name = PersonNameComponents(givenName = "Leland", familyName = "Stanford"), - image = ImageResource.Vector(Icons.Default.AccountBox), + name = PersonNameComponents( + givenName = "Leland", + familyName = "Stanford" + ), + image = ImageResource.Vector(Icons.Default.AccountBox, StringResource("Account Box")), title = StringResource("University Founder"), description = StringResource(""" Amasa Leland Stanford (March 9, 1824 – June 21, 1893) was an American industrialist and politician. [...] \ @@ -42,8 +45,11 @@ He and his wife Jane were also the founders of Stanford University, which they n ) val mock = Contact( - name = PersonNameComponents(givenName = "Paul", familyName = "Schmiedmayer"), - image = ImageResource.Vector(Icons.Default.AccountBox), + name = PersonNameComponents( + givenName = "Paul", + familyName = "Schmiedmayer" + ), + image = ImageResource.Vector(Icons.Default.AccountBox, StringResource("Account Box")), title = StringResource("A Title"), description = StringResource(""" This is a description of a contact that will be displayed. It might even be longer than what has to be displayed in the contact card. 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..26da4f5ee 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( @@ -48,7 +48,7 @@ class ContactComposableSimulator( imageResource?.let { image(imageResource) .assertExists() - .assertContentDescriptionContains("Profile Picture") + .assertContentDescriptionContains("Account Box") .assertImageIdentifier(it.identifier) } } diff --git a/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/ContactComposable.kt b/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/ContactComposable.kt index 5da9e7e63..0afab7cb7 100644 --- a/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/ContactComposable.kt +++ b/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/ContactComposable.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import edu.stanford.spezi.core.design.component.ImageResource @@ -32,12 +31,12 @@ import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.SpeziTheme import edu.stanford.spezi.core.design.theme.TextStyles import edu.stanford.spezi.core.design.theme.ThemePreviews +import edu.stanford.spezi.core.design.views.personalinfo.PersonNameComponents import edu.stanford.spezi.core.utils.extensions.testIdentifier import edu.stanford.spezi.modules.contact.component.AddressCard import edu.stanford.spezi.modules.contact.component.ContactOptionCard import edu.stanford.spezi.modules.contact.model.Contact import edu.stanford.spezi.modules.contact.model.ContactOption -import edu.stanford.spezi.modules.contact.model.PersonNameComponents import edu.stanford.spezi.modules.contact.model.call import edu.stanford.spezi.modules.contact.model.email import edu.stanford.spezi.modules.contact.model.text @@ -78,7 +77,6 @@ fun ContactComposable(contact: Contact, modifier: Modifier = Modifier) { ) { ImageResourceComposable( imageResource = contact.image, - contentDescription = stringResource(R.string.profile_picture), modifier = Modifier .size(Sizes.Icon.medium) ) @@ -197,8 +195,11 @@ private object ContactComposableFactory { ), ): Contact { return Contact( - name = PersonNameComponents(givenName = "Leland", familyName = "Stanford"), - image = ImageResource.Vector(Icons.Default.AccountBox), + name = PersonNameComponents( + givenName = "Leland", + familyName = "Stanford" + ), + image = ImageResource.Vector(Icons.Default.AccountBox, StringResource(R.string.profile_picture)), title = title, description = description, organization = StringResource("Stanford University"), diff --git a/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/Contact.kt b/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/Contact.kt index 6d0ffefec..3547105c3 100644 --- a/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/Contact.kt +++ b/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/Contact.kt @@ -5,6 +5,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBox 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.modules.contact.R import java.util.UUID /** @@ -24,7 +26,7 @@ import java.util.UUID data class Contact( val id: UUID = UUID.randomUUID(), val name: PersonNameComponents, - val image: ImageResource = ImageResource.Vector(Icons.Default.AccountBox), + val image: ImageResource = ImageResource.Vector(Icons.Default.AccountBox, StringResource(R.string.profile_picture)), val title: StringResource? = null, val description: StringResource? = null, val organization: StringResource? = null, diff --git a/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/PersonNameComponents.kt b/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/PersonNameComponents.kt deleted file mode 100644 index d2987a340..000000000 --- a/modules/contact/src/main/kotlin/edu/stanford/spezi/modules/contact/model/PersonNameComponents.kt +++ /dev/null @@ -1,22 +0,0 @@ -package edu.stanford.spezi.modules.contact.model - -data class PersonNameComponents( - val namePrefix: String? = null, - val givenName: String? = null, - val middleName: String? = null, - val familyName: String? = null, - val nameSuffix: String? = null, - val nickname: String? = null, -) { - fun formatted(): String { - val components = listOfNotNull( - namePrefix, - givenName, - nickname?.let { "\"$it\"" }, - middleName, - familyName, - nameSuffix - ) - return components.joinToString(" ") - } -} diff --git a/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/component/ExpandableVideoSection.kt b/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/component/ExpandableVideoSection.kt index dec393094..ef483dbb5 100644 --- a/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/component/ExpandableVideoSection.kt +++ b/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/component/ExpandableVideoSection.kt @@ -35,10 +35,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import edu.stanford.spezi.core.design.component.AsyncImageResource +import edu.stanford.spezi.core.design.component.AsyncImageResourceComposable import edu.stanford.spezi.core.design.component.DefaultElevatedCard -import edu.stanford.spezi.core.design.component.ImageResource -import edu.stanford.spezi.core.design.component.ImageResourceComposable import edu.stanford.spezi.core.design.component.RectangleShimmerEffect +import edu.stanford.spezi.core.design.component.StringResource import edu.stanford.spezi.core.design.component.VerticalSpacer import edu.stanford.spezi.core.design.component.height import edu.stanford.spezi.core.design.theme.Colors @@ -160,9 +161,8 @@ private fun VideoItem(video: Video, onVideoClick: () -> Unit) { .padding(Spacings.small) .fillMaxWidth() ) { - ImageResourceComposable( - imageResource = ImageResource.Remote(video.thumbnailUrl), - contentDescription = "Video thumbnail", + AsyncImageResourceComposable( + imageResource = AsyncImageResource.Remote(url = video.thumbnailUrl, StringResource("Video thumbnail")), modifier = Modifier .fillMaxWidth() .aspectRatio(ASPECT_16_9)