diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt index 2801adc060e..ac1c7ca2b82 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt @@ -18,35 +18,32 @@ package com.wire.android.ui.authentication.create.common.handle import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.R import com.wire.android.ui.common.ShakeAnimation import com.wire.android.ui.common.error.CoreFailureErrorDialog +import com.wire.android.ui.common.textfield.DefaultEmail import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.common.textfield.maxLengthWithCallback +import com.wire.android.ui.common.textfield.patternWithCallback import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.Patterns -@OptIn(ExperimentalComposeUiApi::class) @Composable fun UsernameTextField( - animateUsernameError: Boolean, errorState: HandleUpdateErrorState, - username: TextFieldValue, + username: TextFieldState, onErrorDismiss: () -> Unit, - onUsernameChange: (TextFieldValue) -> Unit, - onUsernameErrorAnimated: () -> Unit ) { if (errorState is HandleUpdateErrorState.DialogError.GenericError) { CoreFailureErrorDialog(errorState.coreFailure, onErrorDismiss) @@ -54,15 +51,13 @@ fun UsernameTextField( val keyboardController = LocalSoftwareKeyboardController.current ShakeAnimation { animate -> - if (animateUsernameError) { - animate() - onUsernameErrorAnimated() - } WireTextField( - value = username, - onValueChange = onUsernameChange, + textState = username, placeholderText = stringResource(R.string.create_account_username_placeholder), labelText = stringResource(R.string.create_account_username_label), + inputTransformation = InputTransformation + .patternWithCallback(Patterns.HANDLE, animate) + .maxLengthWithCallback(255, animate), leadingIcon = { Icon( painter = painterResource(id = R.drawable.ic_mention), @@ -81,8 +76,8 @@ fun UsernameTextField( WireTextFieldState.Error(stringResource(id = R.string.create_account_username_description)) } else WireTextFieldState.Default, descriptionText = stringResource(id = R.string.create_account_username_description), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + keyboardOptions = KeyboardOptions.DefaultEmail, + onKeyboardAction = { keyboardController?.hide() }, modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.spacing16x) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt index 18dc6a7570b..5b007c6e4b5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import com.wire.android.ui.common.scaffold.WireScaffold @@ -177,7 +176,7 @@ private fun EmailContent( state = if (state.error is CreateAccountEmailViewState.EmailError.None) WireTextFieldState.Default else WireTextFieldState.Error(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onKeyboardAction = { keyboardController?.hide() }, modifier = Modifier .padding(horizontal = MaterialTheme.wireDimensions.spacing16x) .testTag("emailField") diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt index 6410b5e8e33..96ba5899158 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameScreen.kt @@ -23,15 +23,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination @@ -43,10 +41,13 @@ import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.create.common.handle.UsernameTextField import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.destinations.InitialSyncScreenDestination +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes @RootNavGraph @Destination @@ -56,25 +57,23 @@ fun CreateAccountUsernameScreen( viewModel: CreateAccountUsernameViewModel = hiltViewModel() ) { UsernameContent( + textState = viewModel.textState, state = viewModel.state, - onUsernameChange = viewModel::onUsernameChange, onContinuePressed = { viewModel.onContinue { navigator.navigate(NavigationCommand(InitialSyncScreenDestination, BackStackMode.CLEAR_WHOLE)) } }, onErrorDismiss = viewModel::onErrorDismiss, - onUsernameErrorAnimated = viewModel::onUsernameErrorAnimated ) } @Composable private fun UsernameContent( + textState: TextFieldState, state: CreateAccountUsernameViewState, - onUsernameChange: (TextFieldValue) -> Unit, onContinuePressed: () -> Unit, onErrorDismiss: () -> Unit, - onUsernameErrorAnimated: () -> Unit ) { WireScaffold( topBar = { @@ -102,11 +101,8 @@ private fun UsernameContent( ) UsernameTextField( - username = state.username, + username = textState, errorState = state.error, - animateUsernameError = state.animateUsernameError, - onUsernameChange = onUsernameChange, - onUsernameErrorAnimated = onUsernameErrorAnimated, onErrorDismiss = onErrorDismiss, ) @@ -126,7 +122,7 @@ private fun UsernameContent( } @Composable -@Preview -private fun PreviewCreateAccountUsernameScreen() { - UsernameContent(CreateAccountUsernameViewState(), {}, {}, {}, {}) +@PreviewMultipleThemes +private fun PreviewCreateAccountUsernameScreen() = WireTheme { + UsernameContent(TextFieldState(), CreateAccountUsernameViewState(), {}, {}) } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt index 20596c0d360..da44c44b9bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt @@ -18,18 +18,21 @@ package com.wire.android.ui.authentication.create.username +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState +import com.wire.android.ui.common.textfield.textAsFlow import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase import com.wire.kalium.logic.feature.user.SetUserHandleResult import com.wire.kalium.logic.feature.user.SetUserHandleUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import javax.inject.Inject @@ -38,40 +41,30 @@ class CreateAccountUsernameViewModel @Inject constructor( private val validateUserHandleUseCase: ValidateUserHandleUseCase, private val setUserHandleUseCase: SetUserHandleUseCase ) : ViewModel() { - var state: CreateAccountUsernameViewState by mutableStateOf(CreateAccountUsernameViewState()) - private set - - fun onUsernameChange(newText: TextFieldValue) { - state = validateUserHandleUseCase(newText.text).let { textState -> - when (textState) { - is ValidateUserHandleResult.Valid -> state.copy( - username = newText.copy(text = textState.handle), - error = HandleUpdateErrorState.None, - continueEnabled = !state.loading, - animateUsernameError = false - ) - is ValidateUserHandleResult.Invalid.InvalidCharacters -> state.copy( - username = newText.copy(text = textState.handle), - error = HandleUpdateErrorState.None, - continueEnabled = !state.loading, - animateUsernameError = true - ) + val textState: TextFieldState = TextFieldState() + var state: CreateAccountUsernameViewState by mutableStateOf(CreateAccountUsernameViewState(continueEnabled = false)) + private set - is ValidateUserHandleResult.Invalid.TooLong -> state.copy( - username = newText.copy(text = textState.handle), - error = HandleUpdateErrorState.None, - continueEnabled = false, - animateUsernameError = false - ) + init { + viewModelScope.launch { + textState.textAsFlow() + .dropWhile { it.isEmpty() } // ignore first empty value to not show the error before the user typed anything + .collectLatest { newHandle -> + validateUserHandleUseCase(newHandle.toString()).let { validateResult -> + state = when (validateResult) { + is ValidateUserHandleResult.Valid -> state.copy( + error = HandleUpdateErrorState.None, + continueEnabled = !state.loading, + ) - is ValidateUserHandleResult.Invalid.TooShort -> state.copy( - username = newText.copy(text = textState.handle), - error = HandleUpdateErrorState.None, - continueEnabled = false, - animateUsernameError = false - ) - } + is ValidateUserHandleResult.Invalid -> state.copy( + error = HandleUpdateErrorState.TextFieldError.UsernameInvalidError, + continueEnabled = false, + ) + } + } + } } } @@ -82,22 +75,11 @@ class CreateAccountUsernameViewModel @Inject constructor( fun onContinue(onSuccess: () -> Unit) { state = state.copy(loading = true, continueEnabled = false) viewModelScope.launch { - // FIXME: no need to check the handle again since it's checked every time the text change - val usernameError = if (validateUserHandleUseCase(state.username.text.trim()) is ValidateUserHandleResult.Invalid) { - HandleUpdateErrorState.TextFieldError.UsernameInvalidError - } else { - when (val result = setUserHandleUseCase(state.username.text.trim())) { - is SetUserHandleResult.Failure.Generic -> - HandleUpdateErrorState.DialogError.GenericError(result.error) - - SetUserHandleResult.Failure.HandleExists -> - HandleUpdateErrorState.TextFieldError.UsernameTakenError - - SetUserHandleResult.Failure.InvalidHandle -> - HandleUpdateErrorState.TextFieldError.UsernameInvalidError - - SetUserHandleResult.Success -> HandleUpdateErrorState.None - } + val usernameError = when (val result = setUserHandleUseCase(textState.text.toString().trim())) { + is SetUserHandleResult.Failure.Generic -> HandleUpdateErrorState.DialogError.GenericError(result.error) + SetUserHandleResult.Failure.HandleExists -> HandleUpdateErrorState.TextFieldError.UsernameTakenError + SetUserHandleResult.Failure.InvalidHandle -> HandleUpdateErrorState.TextFieldError.UsernameInvalidError + SetUserHandleResult.Success -> HandleUpdateErrorState.None } state = state.copy(loading = false, continueEnabled = true, error = usernameError) if (usernameError is HandleUpdateErrorState.None) { @@ -105,8 +87,4 @@ class CreateAccountUsernameViewModel @Inject constructor( } } } - - fun onUsernameErrorAnimated() { - state = state.copy(animateUsernameError = false) - } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewState.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewState.kt index d9a41641479..e8987d12df2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewState.kt @@ -18,12 +18,9 @@ package com.wire.android.ui.authentication.create.username -import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState data class CreateAccountUsernameViewState( - val username: TextFieldValue = TextFieldValue(""), - val animateUsernameError: Boolean = false, val continueEnabled: Boolean = false, val loading: Boolean = false, val error: HandleUpdateErrorState = HandleUpdateErrorState.None diff --git a/app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt b/app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt index a98329774e1..f1a1c35d04f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt @@ -21,34 +21,31 @@ package com.wire.android.ui.common import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.keyframes import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @Composable -fun ShakeAnimation(offset: Dp = 8.dp, duration: Int = 160, animateContent: @Composable (() -> Unit) -> Unit) { +fun ShakeAnimation(offset: Dp = dimensions().spacing12x, duration: Int = 160, animateContent: @Composable (() -> Unit) -> Unit) { val offsetX = remember { Animatable(0f) } val coroutineScope = rememberCoroutineScope() val animate: () -> Unit = { coroutineScope.launch { - launch { - repeat(3) { - offsetX.animateTo( - targetValue = 0f, // return to the starting position - animationSpec = keyframes { - durationMillis = duration - offset.value at duration / 4 // max right offset after 25% of animation time - -offset.value at duration * 3 / 4 // max left offset after 75% of animation time - } - ) + offsetX.animateTo( + targetValue = 0f, // return to the starting position + animationSpec = keyframes { + durationMillis = duration + offset.value at duration * 1 / 8 // max right offset after 12.5% of animation time + -offset.value at duration * 3 / 8 // max left offset after 37.5% of animation time (passes 0 at 25% of animation time) + offset.value at duration * 5 / 8 // max right offset after 62.5% of animation time (passes 0 at 50% of animation time) + -offset.value at duration * 7 / 8 // max left offset after 87.5% of animation time (passes 0 at 75% of animation time) } - } + ) } } - Box(modifier = Modifier.offset(x = offsetX.value.dp)) { animateContent(animate) } + Box(modifier = Modifier.graphicsLayer { translationX = offsetX.value }) { animateContent(animate) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt index 34f86264735..19ba21fa223 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -114,7 +113,7 @@ fun GroupNameScreen( labelText = stringResource(R.string.group_name_title).uppercase(), state = computeGroupMetadataState(error), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onKeyboardAction = { keyboardController?.hide() }, modifier = Modifier.padding(horizontal = MaterialTheme.wireDimensions.spacing16x) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt index a4808848f06..66f104b5080 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt @@ -18,17 +18,16 @@ package com.wire.android.ui.common.textfield -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,8 +75,8 @@ fun CodeTextField( state = textState, textStyle = textStyle, enabled = enabled, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, autoCorrect = false, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + keyboardOptions = KeyboardOptions.DefaultCode, + onKeyboardAction = { keyboardController?.hide() }, interactionSource = interactionSource, inputTransformation = InputTransformation.maxLengthDigits(codeLength), decorator = decorator, @@ -126,8 +125,8 @@ fun CodeTextField( state = textState, textStyle = textStyle, enabled = enabled, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, autoCorrect = false, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + keyboardOptions = KeyboardOptions.DefaultCode, + onKeyboardAction = { keyboardController?.hide() }, interactionSource = interactionSource, inputTransformation = MaxLengthDigitsFilter(codeLength), decorator = decorator, @@ -139,14 +138,20 @@ fun CodeTextField( data class CodeFieldValue(val text: TextFieldValue, val isFullyFilled: Boolean) -@OptIn(ExperimentalFoundationApi::class) +@Stable +val KeyboardOptions.Companion.DefaultCode: KeyboardOptions + get() = Default.copy( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + ) + @PreviewMultipleThemes @Composable fun PreviewCodeTextFieldSuccess() = WireTheme { CodeTextField(textState = rememberTextFieldState("123")) } -@OptIn(ExperimentalFoundationApi::class) @PreviewMultipleThemes @Composable fun PreviewCodeTextFieldError() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt index b684a420e4e..599afb48778 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.common.textfield import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.FocusInteraction @@ -58,7 +57,6 @@ import io.github.esentsov.PackagePrivate import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -@OptIn(ExperimentalFoundationApi::class) @PackagePrivate @Composable internal fun CodeTextFieldLayout( diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt index b516d568f38..01a8c8e6c77 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt @@ -19,30 +19,63 @@ package com.wire.android.ui.common.textfield -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer -import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.then import androidx.compose.runtime.Stable +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.maxTextLength import androidx.compose.ui.text.input.KeyboardType import androidx.core.text.isDigitsOnly +import java.util.regex.Pattern -@OptIn(ExperimentalFoundationApi::class) class MaxLengthDigitsFilter(private val maxLength: Int) : InputTransformation { override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) init { require(maxLength >= 0) { "maxLength must be at least zero, was $maxLength" } } - override fun transformInput(originalValue: TextFieldCharSequence, valueWithChanges: TextFieldBuffer) { - val newLength = valueWithChanges.length - if (newLength > maxLength || !valueWithChanges.asCharSequence().isDigitsOnly()) { - valueWithChanges.revertAllChanges() + override fun SemanticsPropertyReceiver.applySemantics() { + maxTextLength = maxLength + } + override fun TextFieldBuffer.transformInput() { + if (length > maxLength || !asCharSequence().isDigitsOnly()) { + revertAllChanges() } } } -@OptIn(ExperimentalFoundationApi::class) @Stable fun InputTransformation.maxLengthDigits(maxLength: Int): InputTransformation = this.then(MaxLengthDigitsFilter(maxLength)) + +class MaxLengthFilterWithCallback(private val maxLength: Int, private val onIncorrectChangesFound: () -> Unit) : InputTransformation { + init { + require(maxLength >= 0) { "maxLength must be at least zero, was $maxLength" } + } + override fun SemanticsPropertyReceiver.applySemantics() { + maxTextLength = maxLength + } + override fun TextFieldBuffer.transformInput() { + if (length > maxLength) { + revertAllChanges() + onIncorrectChangesFound() + } + } +} + +@Stable +fun InputTransformation.maxLengthWithCallback(maxLength: Int, onIncorrectChangesFound: () -> Unit): InputTransformation = + this.then(MaxLengthFilterWithCallback(maxLength, onIncorrectChangesFound)) + +class PatternFilterWithCallback(private val pattern: Pattern, private val onIncorrectChangesFound: () -> Unit) : InputTransformation { + override fun TextFieldBuffer.transformInput() { + if (!pattern.matcher(asCharSequence()).matches()) { + revertAllChanges() + onIncorrectChangesFound() + } + } +} + +@Stable +fun InputTransformation.patternWithCallback(pattern: Pattern, onIncorrectChangesFound: () -> Unit): InputTransformation = + this.then(PatternFilterWithCallback(pattern, onIncorrectChangesFound)) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/StateSyncingModifier.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/StateSyncingModifier.kt index cf1cb821d9f..c8949442676 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/StateSyncingModifier.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/StateSyncingModifier.kt @@ -17,8 +17,6 @@ */ package com.wire.android.ui.common.textfield -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.text.input.TextFieldCharSequence import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.Modifier import androidx.compose.ui.node.ModifierNodeElement @@ -55,7 +53,6 @@ internal class StateSyncingModifier( override fun InspectorInfo.inspectableProperties() {} } -@OptIn(ExperimentalFoundationApi::class) @PackagePrivate internal class StateSyncingModifierNode( private val state: TextFieldState, @@ -66,12 +63,12 @@ internal class StateSyncingModifierNode( fun update(value: TextFieldValue, onValueChanged: (TextFieldValue) -> Unit) { this.onValueChanged = onValueChanged - if (value.text != state.text.toString() || value.selection != state.text.selection) { + if (value.text != state.text.toString() || value.selection != state.selection) { state.edit { if (value.text != state.text.toString()) { replace(0, length, value.text) } - if (value.selection != state.text.selection) { + if (value.selection != state.selection) { selection = value.selection } } @@ -88,13 +85,17 @@ internal class StateSyncingModifierNode( } private fun observeTextState(fireOnValueChanged: Boolean = true) { - lateinit var text: TextFieldCharSequence + lateinit var value: TextFieldValue + observeReads { - text = state.text + value = TextFieldValue( + state.text.toString(), + state.selection, + state.composition + ) } if (fireOnValueChanged) { - val newValue = TextFieldValue(text.toString(), text.selection, text.composition) - onValueChanged(newValue) + onValueChanged(value) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt index 48f5f36ce80..cc5ff8a1762 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt @@ -18,15 +18,14 @@ package com.wire.android.ui.common.textfield -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicSecureTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextObfuscationMode import androidx.compose.foundation.text.input.maxLength @@ -41,6 +40,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,6 +52,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection @@ -65,7 +67,6 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY import com.wire.android.util.ui.PreviewMultipleThemes -@OptIn(ExperimentalFoundationApi::class) @Composable fun WirePasswordTextField( textState: TextFieldState, @@ -76,9 +77,8 @@ fun WirePasswordTextField( state: WireTextFieldState = WireTextFieldState.Default, autoFill: Boolean = false, inputTransformation: InputTransformation = InputTransformation.maxLength(8000), - imeAction: ImeAction = ImeAction.Default, - onImeAction: (() -> Unit)? = null, - scrollState: ScrollState = rememberScrollState(), + keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultPassword, + onKeyboardAction: KeyboardActionHandler? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), @@ -113,11 +113,10 @@ fun WirePasswordTextField( BasicSecureTextField( state = textState, textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), - imeAction = imeAction, - onSubmit = { onImeAction?.invoke().let { onImeAction != null } }, + keyboardOptions = keyboardOptions, + onKeyboardAction = onKeyboardAction, inputTransformation = inputTransformation, textObfuscationMode = if (passwordVisibility) TextObfuscationMode.Visible else TextObfuscationMode.RevealLastTyped, - scrollState = scrollState, enabled = state !is WireTextFieldState.Disabled, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), interactionSource = interactionSource, @@ -134,7 +133,6 @@ TODO: BasicSecureTextField (value, onValueChange) overload is removed completely but eventually we should migrate and remove this function when all usages are replaced with the TextFieldState. */ @Deprecated("Use the new one with TextFieldState.") -@OptIn(ExperimentalFoundationApi::class) @Composable fun WirePasswordTextField( value: TextFieldValue, @@ -147,8 +145,7 @@ fun WirePasswordTextField( autofill: Boolean, maxTextLength: Int = 8000, imeAction: ImeAction = ImeAction.Default, - onImeAction: (() -> Unit)? = null, - scrollState: ScrollState = rememberScrollState(), + onImeAction: KeyboardActionHandler? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), @@ -184,11 +181,10 @@ fun WirePasswordTextField( BasicSecureTextField( state = textState, textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), - imeAction = imeAction, - onSubmit = { onImeAction?.invoke().let { onImeAction != null } }, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = imeAction), + onKeyboardAction = onImeAction, inputTransformation = InputTransformation.maxLength(maxTextLength), textObfuscationMode = if (passwordVisibility) TextObfuscationMode.Visible else TextObfuscationMode.RevealLastTyped, - scrollState = scrollState, enabled = state !is WireTextFieldState.Disabled, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), interactionSource = interactionSource, @@ -215,7 +211,15 @@ private fun VisibilityIconButton(isVisible: Boolean, onVisibleChange: (Boolean) } } -@OptIn(ExperimentalFoundationApi::class) +@Stable +val KeyboardOptions.Companion.DefaultPassword: KeyboardOptions + get() = Default.copy( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None + ) + @PreviewMultipleThemes @Composable fun PreviewWirePasswordTextField() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index a2e90142e54..3671f2c69e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -18,17 +18,16 @@ package com.wire.android.ui.common.textfield -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState @@ -42,6 +41,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,7 +50,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density @@ -62,7 +64,6 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY import com.wire.android.util.ui.PreviewMultipleThemes -@OptIn(ExperimentalFoundationApi::class) @Composable internal fun WireTextField( textState: TextFieldState, @@ -77,8 +78,8 @@ internal fun WireTextField( lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, inputTransformation: InputTransformation = InputTransformation.maxLength(8000), outputTransformation: OutputTransformation? = null, - keyboardOptions: KeyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), - keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultText, + onKeyboardAction: KeyboardActionHandler? = null, scrollState: ScrollState = rememberScrollState(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = MaterialTheme.wireTypography.body01, @@ -116,7 +117,7 @@ internal fun WireTextField( state = textState, textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, + onKeyboardAction = onKeyboardAction, lineLimits = lineLimits, inputTransformation = inputTransformation, outputTransformation = outputTransformation, @@ -138,7 +139,6 @@ TODO: BasicTextField2 (value, onValueChange) overload is removed completely in c for now we can use our custom StateSyncingModifier to sync TextFieldValue with TextFieldState, but eventually we should migrate and remove this function when all usages are replaced with the TextFieldState. */ -@OptIn(ExperimentalFoundationApi::class) @Deprecated("Use the new one with TextFieldState.") @Composable internal fun WireTextField( @@ -156,8 +156,8 @@ internal fun WireTextField( maxLines: Int = 1, singleLine: Boolean = true, maxTextLength: Int = 8000, - keyboardOptions: KeyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), - keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultText, + onKeyboardAction: KeyboardActionHandler? = null, scrollState: ScrollState = rememberScrollState(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = MaterialTheme.wireTypography.body01, @@ -197,7 +197,7 @@ internal fun WireTextField( state = textState, textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, + onKeyboardAction = onKeyboardAction, lineLimits = lineLimits, inputTransformation = InputTransformation.maxLength(maxTextLength), scrollState = scrollState, @@ -213,21 +213,37 @@ internal fun WireTextField( ) } -@OptIn(ExperimentalFoundationApi::class) private fun onTextLayout( state: TextFieldState, onSelectedLineIndexChanged: (Int) -> Unit = { }, onLineBottomYCoordinateChanged: (Float) -> Unit = { }, ): (Density.(getResult: () -> TextLayoutResult?) -> Unit) = { it()?.let { - val lineOfText = it.getLineForOffset(state.text.selection.end) + val lineOfText = it.getLineForOffset(state.selection.end) val bottomYCoordinate = it.getLineBottom(lineOfText) onSelectedLineIndexChanged(lineOfText) onLineBottomYCoordinateChanged(bottomYCoordinate) } } -@OptIn(ExperimentalFoundationApi::class) +@Stable +val KeyboardOptions.Companion.DefaultText: KeyboardOptions + get() = Default.copy( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + autoCorrectEnabled = true, + capitalization = KeyboardCapitalization.Sentences, + ) + +@Stable +val KeyboardOptions.Companion.DefaultEmail: KeyboardOptions + get() = Default.copy( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None, + ) + @PreviewMultipleThemes @Composable fun PreviewWireTextField() = WireTheme { @@ -237,7 +253,6 @@ fun PreviewWireTextField() = WireTheme { ) } -@OptIn(ExperimentalFoundationApi::class) @PreviewMultipleThemes @Composable fun PreviewWireTextFieldLabels() = WireTheme { @@ -250,7 +265,6 @@ fun PreviewWireTextFieldLabels() = WireTheme { ) } -@OptIn(ExperimentalFoundationApi::class) @PreviewMultipleThemes @Composable fun PreviewWireTextFieldDenseSearch() = WireTheme { @@ -264,7 +278,6 @@ fun PreviewWireTextFieldDenseSearch() = WireTheme { ) } -@OptIn(ExperimentalFoundationApi::class) @PreviewMultipleThemes @Composable fun PreviewWireTextFieldDisabled() = WireTheme { @@ -275,7 +288,6 @@ fun PreviewWireTextFieldDisabled() = WireTheme { ) } -@OptIn(ExperimentalFoundationApi::class) @PreviewMultipleThemes @Composable fun PreviewWireTextFieldError() = WireTheme { @@ -286,7 +298,6 @@ fun PreviewWireTextFieldError() = WireTheme { ) } -@OptIn(ExperimentalFoundationApi::class) @PreviewMultipleThemes @Composable fun PreviewWireTextFieldSuccess() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt index 957e1972281..b26bf6c6a1f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt @@ -17,10 +17,13 @@ */ package com.wire.android.ui.common.textfield +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.coroutines.flow.Flow sealed class WireTextFieldState { data object Default : WireTextFieldState() @@ -35,3 +38,5 @@ sealed class WireTextFieldState { else -> null } } + +fun TextFieldState.textAsFlow(): Flow = snapshotFlow { text } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt index 4192ee45981..86a5051cb3b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt @@ -25,20 +25,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph @@ -52,12 +51,18 @@ import com.wire.android.ui.common.button.WireButtonState.Disabled import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.rememberBottomBarElevationState import com.wire.android.ui.common.rememberTopBarElevationState +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.textfield.DefaultText import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.common.textfield.maxLengthWithCallback import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.home.settings.account.displayname.ChangeDisplayNameViewModel.Companion.NAME_MAX_COUNT +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes @RootNavGraph @Destination @@ -69,9 +74,9 @@ fun ChangeDisplayNameScreen( ) { with(viewModel) { ChangeDisplayNameContent( - displayNameState, - ::onNameChange, - { + textState = viewModel.textState, + state = viewModel.displayNameState, + onContinuePressed = { saveDisplayName( onFailure = { resultNavigator.setResult(false) @@ -83,8 +88,7 @@ fun ChangeDisplayNameScreen( } ) }, - ::onNameErrorAnimated, - navigator::navigateBack + onBackPressed = navigator::navigateBack ) } } @@ -92,10 +96,9 @@ fun ChangeDisplayNameScreen( @OptIn(ExperimentalComposeUiApi::class) @Composable fun ChangeDisplayNameContent( + textState: TextFieldState, state: DisplayNameState, - onNameChange: (TextFieldValue) -> Unit, onContinuePressed: () -> Unit, - onNameErrorAnimated: () -> Unit, onBackPressed: () -> Unit ) { val scrollState = rememberScrollState() @@ -134,21 +137,15 @@ fun ChangeDisplayNameContent( Box { ShakeAnimation { animate -> - if (animatedNameError) { - animate() - onNameErrorAnimated() - } WireTextField( - value = displayName, - onValueChange = onNameChange, + textState = textState, labelText = stringResource(R.string.settings_myaccount_display_name).uppercase(), + inputTransformation = InputTransformation.maxLengthWithCallback(NAME_MAX_COUNT, animate), + lineLimits = TextFieldLineLimits.SingleLine, state = computeNameErrorState(error), - keyboardOptions = KeyboardOptions( - keyboardType = androidx.compose.ui.text.input.KeyboardType.Text, - imeAction = androidx.compose.ui.text.input.ImeAction.Done - ), + keyboardOptions = KeyboardOptions.DefaultText, descriptionText = stringResource(id = R.string.settings_myaccount_display_name_exceeded_limit_error), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onKeyboardAction = { keyboardController?.hide() }, modifier = Modifier.padding( horizontal = MaterialTheme.wireDimensions.spacing16x ) @@ -168,7 +165,8 @@ fun ChangeDisplayNameContent( onClick = onContinuePressed, fillMaxWidth = true, trailingIcon = androidx.compose.material.icons.Icons.Filled.ChevronRight.Icon(), - state = if (continueEnabled) Default else Disabled, + state = if (saveEnabled) Default else Disabled, + loading = loading, modifier = Modifier.fillMaxWidth() ) } @@ -194,8 +192,8 @@ private fun computeNameErrorState(error: DisplayNameState.NameError) = WireTextFieldState.Default } -@Preview +@PreviewMultipleThemes @Composable -fun PreviewChangeDisplayName() { - ChangeDisplayNameContent(DisplayNameState("Bruce Wayne", TextFieldValue("Bruce Wayne")), {}, {}, {}, {}) +fun PreviewChangeDisplayName() = WireTheme { + ChangeDisplayNameContent(TextFieldState("Bruce Wayne"), DisplayNameState(), {}, {}) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt index f51535fc5c5..bc9f474b5d1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt @@ -18,20 +18,20 @@ package com.wire.android.ui.home.settings.account.displayname +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.ui.common.textfield.textAsFlow import com.wire.kalium.logic.feature.user.DisplayNameUpdateResult import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.UpdateDisplayNameUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,64 +39,26 @@ import javax.inject.Inject class ChangeDisplayNameViewModel @Inject constructor( private val getSelf: GetSelfUserUseCase, private val updateDisplayName: UpdateDisplayNameUseCase, - private val dispatchers: DispatcherProvider ) : ViewModel() { + val textState: TextFieldState = TextFieldState() var displayNameState: DisplayNameState by mutableStateOf(DisplayNameState()) private set init { viewModelScope.launch { - getSelf().flowOn(dispatchers.io()).shareIn(this, SharingStarted.WhileSubscribed(1)).collect { - displayNameState = displayNameState.copy( - originalDisplayName = it.name.orEmpty(), - displayName = TextFieldValue(it.name.orEmpty()) - ) - } - } - } - - fun onNameChange(newText: TextFieldValue) { - displayNameState = validateNewNameChange(newText) - } - - private fun validateNewNameChange(newText: TextFieldValue): DisplayNameState { - val cleanText = newText.text.trim() - return when { - cleanText.isEmpty() -> { - displayNameState.copy( - animatedNameError = true, - displayName = newText, - continueEnabled = false, - error = DisplayNameState.NameError.TextFieldError.NameEmptyError - ) - } - - cleanText.count() > NAME_MAX_COUNT -> { - displayNameState.copy( - animatedNameError = true, - displayName = newText, - continueEnabled = false, - error = DisplayNameState.NameError.TextFieldError.NameExceedLimitError - ) - } - - cleanText == displayNameState.originalDisplayName -> { - displayNameState.copy( - animatedNameError = false, - displayName = newText, - continueEnabled = false, - error = DisplayNameState.NameError.None - ) - } - - else -> { - displayNameState.copy( - animatedNameError = false, - displayName = newText, - continueEnabled = true, - error = DisplayNameState.NameError.None - ) + getSelf().firstOrNull()?.name.orEmpty().let { currentDisplayName -> + textState.setTextAndPlaceCursorAtEnd(currentDisplayName) + textState.textAsFlow().collectLatest { + displayNameState = displayNameState.copy( + saveEnabled = it.trim().isNotEmpty() && it.length <= NAME_MAX_COUNT && it.trim() != currentDisplayName, + error = when { + it.trim().isEmpty() -> DisplayNameState.NameError.TextFieldError.NameEmptyError + it.length > NAME_MAX_COUNT -> DisplayNameState.NameError.TextFieldError.NameExceedLimitError + else -> DisplayNameState.NameError.None + } + ) + } } } } @@ -105,19 +67,20 @@ class ChangeDisplayNameViewModel @Inject constructor( onFailure: () -> Unit, onSuccess: () -> Unit, ) { + displayNameState = displayNameState.copy(loading = true) viewModelScope.launch { - when (updateDisplayName(displayNameState.displayName.text)) { - is DisplayNameUpdateResult.Failure -> onFailure() - is DisplayNameUpdateResult.Success -> onSuccess() - } + updateDisplayName(textState.toString().trim()) + .also { displayNameState = displayNameState.copy(loading = false) } + .let { + when (it) { + is DisplayNameUpdateResult.Failure -> onFailure() + is DisplayNameUpdateResult.Success -> onSuccess() + } + } } } - fun onNameErrorAnimated() { - displayNameState = displayNameState.copy(animatedNameError = false) - } - companion object { - private const val NAME_MAX_COUNT = 64 + const val NAME_MAX_COUNT = 64 } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/DisplayNameState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/DisplayNameState.kt index 71b510b758b..e55d6f46c47 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/DisplayNameState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/DisplayNameState.kt @@ -18,14 +18,10 @@ package com.wire.android.ui.home.settings.account.displayname -import androidx.compose.ui.text.input.TextFieldValue - data class DisplayNameState( - val originalDisplayName: String = "", - val displayName: TextFieldValue = TextFieldValue(""), + val loading: Boolean = false, + val saveEnabled: Boolean = false, val error: NameError = NameError.None, - val animatedNameError: Boolean = false, - val continueEnabled: Boolean = false ) { sealed interface NameError { object None : NameError diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt index 5fc3f2a704b..8c4f8a9644e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailScreen.kt @@ -24,24 +24,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph @@ -56,13 +51,19 @@ import com.wire.android.ui.common.button.WireButtonState.Disabled import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.rememberBottomBarElevationState import com.wire.android.ui.common.rememberTopBarElevationState +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.textfield.DefaultEmail import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.common.textfield.patternWithCallback import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.destinations.VerifyEmailScreenDestination +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.Patterns +import com.wire.android.util.ui.PreviewMultipleThemes @RootNavGraph @Destination @@ -78,22 +79,19 @@ fun ChangeEmailScreen( navigator.navigate(NavigationCommand(VerifyEmailScreenDestination(flowState.newEmail), BackStackMode.REMOVE_CURRENT)) else -> ChangeEmailContent( + textState = viewModel.textState, state = viewModel.state, - onEmailChange = viewModel::onEmailChange, - onEmailErrorAnimated = viewModel::onEmailErrorAnimated, onBackPressed = navigator::navigateBack, onSaveClicked = viewModel::onSaveClicked ) } } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun ChangeEmailContent( + textState: TextFieldState, state: ChangeEmailState, - onEmailChange: (TextFieldValue) -> Unit, onSaveClicked: () -> Unit, - onEmailErrorAnimated: () -> Unit, onBackPressed: () -> Unit, ) { val scrollState = rememberScrollState() @@ -132,20 +130,13 @@ fun ChangeEmailContent( Box { ShakeAnimation { animate -> - if (state.animatedEmailError) { - animate() - onEmailErrorAnimated() - } WireTextField( - value = state.email, - onValueChange = onEmailChange, + textState = textState, labelText = stringResource(R.string.email_label).uppercase(), + inputTransformation = InputTransformation.patternWithCallback(Patterns.EMAIL_ADDRESS, animate), state = computeEmailErrorState(state.flowState), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + keyboardOptions = KeyboardOptions.DefaultEmail, + onKeyboardAction = { keyboardController?.hide() }, modifier = Modifier.padding( horizontal = MaterialTheme.wireDimensions.spacing16x ) @@ -177,32 +168,31 @@ fun ChangeEmailContent( @Composable private fun computeEmailErrorState(state: ChangeEmailState.FlowState): WireTextFieldState = - if (state is ChangeEmailState.FlowState.Error.TextFieldError) { - when (state) { - ChangeEmailState.FlowState.Error.TextFieldError.AlreadyInUse -> WireTextFieldState.Error( - stringResource(id = R.string.settings_myaccount_email_already_in_use_error) - ) + when (state) { + ChangeEmailState.FlowState.Error.TextFieldError.AlreadyInUse -> WireTextFieldState.Error( + stringResource(id = R.string.settings_myaccount_email_already_in_use_error) + ) - ChangeEmailState.FlowState.Error.TextFieldError.InvalidEmail -> WireTextFieldState.Error( - stringResource(id = R.string.settings_myaccount_email_invalid_imail_error) - ) + ChangeEmailState.FlowState.Error.TextFieldError.InvalidEmail -> WireTextFieldState.Error( + stringResource(id = R.string.settings_myaccount_email_invalid_imail_error) + ) - ChangeEmailState.FlowState.Error.TextFieldError.Generic -> WireTextFieldState.Error( - stringResource(id = R.string.settings_myaccount_email_generic_error) - ) - } - } else { - WireTextFieldState.Default + ChangeEmailState.FlowState.Error.TextFieldError.Generic -> WireTextFieldState.Error( + stringResource(id = R.string.settings_myaccount_email_generic_error) + ) + + ChangeEmailState.FlowState.Loading -> WireTextFieldState.ReadOnly + + else -> WireTextFieldState.Default } -@Preview +@PreviewMultipleThemes @Composable -fun PreviewChangeEmailName() { +fun PreviewChangeEmailName() = WireTheme { ChangeEmailContent( + textState = TextFieldState(), state = ChangeEmailState(), onBackPressed = { }, onSaveClicked = { }, - onEmailChange = { }, - onEmailErrorAnimated = { } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailState.kt index 5ed61701bcf..6ed0fdae47c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailState.kt @@ -17,27 +17,22 @@ */ package com.wire.android.ui.home.settings.account.email.updateEmail -import androidx.compose.ui.text.input.TextFieldValue - data class ChangeEmailState( - val email: TextFieldValue = TextFieldValue(""), - val isEmailTextEditEnabled: Boolean = true, - val animatedEmailError: Boolean = false, val saveEnabled: Boolean = false, val flowState: FlowState = FlowState.Default, ) { sealed interface FlowState { - object Default : FlowState - object Loading : FlowState + data object Default : FlowState + data object Loading : FlowState data class Success(val newEmail: String) : FlowState - object NoChange : FlowState + data object NoChange : FlowState sealed interface Error : FlowState { - object SelfUserNotFound : Error + data object SelfUserNotFound : Error sealed interface TextFieldError : Error { - object AlreadyInUse : TextFieldError - object InvalidEmail : TextFieldError - object Generic : TextFieldError + data object AlreadyInUse : TextFieldError + data object InvalidEmail : TextFieldError + data object Generic : TextFieldError } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt index f97da02b068..e5105a2acd9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt @@ -18,16 +18,19 @@ package com.wire.android.ui.home.settings.account.email.updateEmail import androidx.annotation.VisibleForTesting -import android.util.Patterns +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.ui.common.textfield.textAsFlow +import com.wire.android.util.Patterns import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.UpdateEmailUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import javax.inject.Inject @@ -38,21 +41,22 @@ class ChangeEmailViewModel @Inject constructor( private val getSelf: GetSelfUserUseCase, ) : ViewModel() { - var state: ChangeEmailState by mutableStateOf( - ChangeEmailState( - isEmailTextEditEnabled = true - ) - ) + val textState: TextFieldState = TextFieldState() + var state: ChangeEmailState by mutableStateOf(ChangeEmailState()) @VisibleForTesting set - private var currentEmail: String? = null - init { viewModelScope.launch { - getSelf().firstOrNull()?.email?.let { - currentEmail = it - state = state.copy(email = TextFieldValue(it)) + getSelf().firstOrNull()?.email?.let { currentEmail -> + textState.setTextAndPlaceCursorAtEnd(currentEmail) + textState.textAsFlow().collectLatest { + val isValidEmail = Patterns.EMAIL_ADDRESS.matcher(it.trim()).matches() + state = state.copy( + saveEnabled = it.trim().isNotEmpty() && isValidEmail && it.trim() != currentEmail, + flowState = ChangeEmailState.FlowState.Default + ) + } } ?: run { state = state.copy(flowState = ChangeEmailState.FlowState.Error.SelfUserNotFound) } @@ -60,63 +64,33 @@ class ChangeEmailViewModel @Inject constructor( } fun onSaveClicked() { - state = state.copy(saveEnabled = false, isEmailTextEditEnabled = false, flowState = ChangeEmailState.FlowState.Loading) + state = state.copy(saveEnabled = false, flowState = ChangeEmailState.FlowState.Loading) viewModelScope.launch { - when (updateEmail(state.email.text)) { + val email = textState.text.trim().toString() + when (updateEmail(email)) { UpdateEmailUseCase.Result.Failure.EmailAlreadyInUse -> state = state.copy( - isEmailTextEditEnabled = true, saveEnabled = false, flowState = ChangeEmailState.FlowState.Error.TextFieldError.AlreadyInUse, ) UpdateEmailUseCase.Result.Failure.InvalidEmail -> state = state.copy( - isEmailTextEditEnabled = true, saveEnabled = false, flowState = ChangeEmailState.FlowState.Error.TextFieldError.InvalidEmail ) is UpdateEmailUseCase.Result.Failure.GenericFailure -> state = state.copy( - isEmailTextEditEnabled = true, saveEnabled = false, flowState = ChangeEmailState.FlowState.Error.TextFieldError.Generic ) is UpdateEmailUseCase.Result.Success.VerificationEmailSent -> - state = state.copy(flowState = ChangeEmailState.FlowState.Success(state.email.text)) + state = state.copy(flowState = ChangeEmailState.FlowState.Success(email)) is UpdateEmailUseCase.Result.Success.NoChange -> state = state.copy(flowState = ChangeEmailState.FlowState.NoChange) } } } - - fun onEmailChange(newEmail: TextFieldValue) { - val cleanEmail = newEmail.text.trim() - val isValidEmail = Patterns.EMAIL_ADDRESS.matcher(cleanEmail).matches() - when { - cleanEmail.isBlank() -> state = - state.copy( - saveEnabled = false, - email = newEmail, - ) - - cleanEmail == currentEmail -> state = state.copy( - saveEnabled = false, - email = newEmail, - flowState = ChangeEmailState.FlowState.Default - ) - - else -> state = state.copy( - saveEnabled = isValidEmail, - email = newEmail, - flowState = ChangeEmailState.FlowState.Default - ) - } - } - - fun onEmailErrorAnimated() { - state = state.copy(animatedEmailError = false) - } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt index 94c0ce2c887..43b965498e1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleScreen.kt @@ -24,18 +24,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph @@ -49,10 +47,13 @@ import com.wire.android.ui.common.button.WireButtonState.Disabled import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.rememberBottomBarElevationState import com.wire.android.ui.common.rememberTopBarElevationState +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes @RootNavGraph @Destination @@ -63,9 +64,8 @@ fun ChangeHandleScreen( viewModel: ChangeHandleViewModel = hiltViewModel() ) { ChangeHandleContent( + textState = viewModel.textState, state = viewModel.state, - onHandleChanged = viewModel::onHandleChanged, - onHandleErrorAnimated = viewModel::onHandleErrorAnimated, onBackPressed = navigator::navigateBack, onSaveClicked = { viewModel.onSaveClicked() { @@ -79,10 +79,9 @@ fun ChangeHandleScreen( @Composable fun ChangeHandleContent( + textState: TextFieldState, state: ChangeHandleState, - onHandleChanged: (TextFieldValue) -> Unit, onSaveClicked: () -> Unit, - onHandleErrorAnimated: () -> Unit, onErrorDismiss: () -> Unit, onBackPressed: () -> Unit, ) { @@ -122,13 +121,9 @@ fun ChangeHandleContent( Box { UsernameTextField( - username = state.handle, + username = textState, errorState = state.error, - onUsernameChange = onHandleChanged, - animateUsernameError = state.animatedHandleError, - onUsernameErrorAnimated = onHandleErrorAnimated, onErrorDismiss = onErrorDismiss - ) } Spacer(modifier = Modifier.weight(1f)) @@ -153,15 +148,14 @@ fun ChangeHandleContent( } } -@Preview +@PreviewMultipleThemes @Composable -fun PreviewChangeHandleContent() { +fun PreviewChangeHandleContent() = WireTheme { ChangeHandleContent( + textState = TextFieldState(), state = ChangeHandleState(), onBackPressed = { }, onSaveClicked = { }, - onHandleChanged = { }, - onHandleErrorAnimated = { }, onErrorDismiss = { } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleState.kt index d58e782b950..aeea77b8784 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleState.kt @@ -17,12 +17,9 @@ */ package com.wire.android.ui.home.settings.account.handle -import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState data class ChangeHandleState( - val handle: TextFieldValue = TextFieldValue(""), val error: HandleUpdateErrorState = HandleUpdateErrorState.None, val isSaveButtonEnabled: Boolean = false, - val animatedHandleError: Boolean = false, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt index 4ba506ba847..8a4c55cd269 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt @@ -18,19 +18,22 @@ package com.wire.android.ui.home.settings.account.handle import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState +import com.wire.android.ui.common.textfield.textAsFlow import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.SetUserHandleResult import com.wire.kalium.logic.feature.user.SetUserHandleUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import javax.inject.Inject @@ -42,44 +45,34 @@ class ChangeHandleViewModel @Inject constructor( private val getSelf: GetSelfUserUseCase ) : ViewModel() { + val textState: TextFieldState = TextFieldState() var state: ChangeHandleState by mutableStateOf(ChangeHandleState()) @VisibleForTesting set - private var currentHandle: String? = null - init { viewModelScope.launch { - getSelf().firstOrNull()?.handle.let { - currentHandle = it - state = state.copy(handle = TextFieldValue(it.orEmpty())) - } - } - } - - fun onHandleChanged(newHandle: TextFieldValue) { - state = state.copy(handle = newHandle) - viewModelScope.launch { - state = when (validateHandle(newHandle.text)) { - is ValidateUserHandleResult.Invalid.InvalidCharacters, - is ValidateUserHandleResult.Invalid.TooLong, - is ValidateUserHandleResult.Invalid.TooShort -> state.copy( - error = HandleUpdateErrorState.TextFieldError.UsernameInvalidError, - animatedHandleError = true, - isSaveButtonEnabled = false - ) - - is ValidateUserHandleResult.Valid -> state.copy( - error = HandleUpdateErrorState.None, - isSaveButtonEnabled = newHandle.text != currentHandle - ) + getSelf().firstOrNull()?.handle.orEmpty().let { currentHandle -> + textState.setTextAndPlaceCursorAtEnd(currentHandle) + textState.textAsFlow().collectLatest { newHandle -> + state = when (validateHandle(newHandle.toString())) { + is ValidateUserHandleResult.Invalid -> state.copy( + error = HandleUpdateErrorState.TextFieldError.UsernameInvalidError, + isSaveButtonEnabled = false + ) + is ValidateUserHandleResult.Valid -> state.copy( + error = HandleUpdateErrorState.None, + isSaveButtonEnabled = newHandle.toString() != currentHandle + ) + } + } } } } fun onSaveClicked(onSuccess: () -> Unit) { viewModelScope.launch { - when (val result = updateHandle(state.handle.text)) { + when (val result = updateHandle(textState.text.toString().trim())) { is SetUserHandleResult.Failure.Generic -> state = state.copy(error = HandleUpdateErrorState.DialogError.GenericError(result.error)) @@ -94,10 +87,6 @@ class ChangeHandleViewModel @Inject constructor( } } - fun onHandleErrorAnimated() { - state = state.copy(animatedHandleError = false) - } - fun onErrorDismiss() { state = state.copy(error = HandleUpdateErrorState.None) } diff --git a/app/src/main/kotlin/com/wire/android/util/Patterns.kt b/app/src/main/kotlin/com/wire/android/util/Patterns.kt new file mode 100644 index 00000000000..0dd440d6f21 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/Patterns.kt @@ -0,0 +1,33 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import java.util.regex.Pattern + +object Patterns { + val EMAIL_ADDRESS: Pattern = Pattern.compile( + // RFC5322-compliant regex that covers 99.99% of input email addresses. http://emailregex.com/ + "(?:[a-z0-9!#\$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#\$%&'*+/=?^_`{|}~-]+)*|\"" + + "(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@" + + "(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" + + "|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}" + + "(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:" + + "(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])" + ) + val HANDLE: Pattern = Pattern.compile("^[a-z0-9._-]*$") +} diff --git a/app/src/test/kotlin/com/wire/android/config/SnapshotExtension.kt b/app/src/test/kotlin/com/wire/android/config/SnapshotExtension.kt new file mode 100644 index 00000000000..a55c0ba2bbf --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/config/SnapshotExtension.kt @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.config + +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.InvocationInterceptor +import org.junit.jupiter.api.extension.ReflectiveInvocationContext +import java.lang.reflect.Method + +/** + * This extension provides a way to test [androidx.compose.foundation.text.input.TextFieldState]. + * It's needed to manually accept changes to this specific mutable state, which in a running app is normally done by the compose runtime. + * There is no official guide on how to write tests for the TextFieldState, but this is how it's done in the compose source code. + * Take a look at: https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-main/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt#715 + * + * Add this JUnit 5 extension to your test class using + * @JvmField + * @RegisterExtension + * val snapshotExtension = SnapshotExtension() + * + * or: + * + * Annotating the class with + * @ExtendWith(SnapshotExtension::class) + */ +@ExperimentalCoroutinesApi +class SnapshotExtension : InvocationInterceptor { + override fun interceptTestMethod( + invocation: InvocationInterceptor.Invocation?, + invocationContext: ReflectiveInvocationContext?, + extensionContext: ExtensionContext? + ) { + val globalWriteObserverHandle = Snapshot.registerGlobalWriteObserver { + Snapshot.sendApplyNotifications() // This is normally done by the compose runtime. + } + try { + invocation?.proceed() + } finally { + globalWriteObserverHandle.dispose() + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt index 087a4fee4e7..2671dfe8407 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt @@ -18,8 +18,9 @@ package com.wire.android.ui.authentication.create.username -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.SnapshotExtension import com.wire.android.config.mockUri import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState import com.wire.android.util.EMPTY @@ -31,7 +32,6 @@ import com.wire.kalium.logic.feature.user.SetUserHandleUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -40,72 +40,47 @@ import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeInstanceOf -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class CreateAccountUsernameViewModelTest { - @MockK - private lateinit var validateUserHandleUseCase: ValidateUserHandleUseCase - - @MockK - private lateinit var setUserHandleUseCase: SetUserHandleUseCase - - @MockK(relaxed = true) - private lateinit var onSuccess: () -> Unit - - private lateinit var createAccountUsernameViewModel: CreateAccountUsernameViewModel - - @BeforeEach - fun setup() { - MockKAnnotations.init(this) - mockUri() - createAccountUsernameViewModel = CreateAccountUsernameViewModel(validateUserHandleUseCase, setUserHandleUseCase) - } - @Test - fun `given empty string, when entering username, then button is disabled`() { - every { validateUserHandleUseCase.invoke(String.EMPTY) } returns ValidateUserHandleResult.Invalid.TooShort(String.EMPTY) - createAccountUsernameViewModel.onUsernameChange(TextFieldValue(String.EMPTY)) + fun `given empty string, when entering username, then button is disabled`() = runTest { + val username = String.EMPTY + val (_, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Invalid.TooShort(username)) + .arrange() + createAccountUsernameViewModel.textState.setTextAndPlaceCursorAtEnd(username) createAccountUsernameViewModel.state.continueEnabled shouldBeEqualTo false createAccountUsernameViewModel.state.loading shouldBeEqualTo false } @Test - fun `given non-empty string, when entering username, then button is disabled`() { - every { validateUserHandleUseCase.invoke("abc") } returns ValidateUserHandleResult.Valid("abc") - createAccountUsernameViewModel.onUsernameChange(TextFieldValue("abc")) + fun `given non-empty string, when entering username, then button is disabled`() = runTest { + val username = "abc" + val (_, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid(username)) + .arrange() + createAccountUsernameViewModel.textState.setTextAndPlaceCursorAtEnd(username) createAccountUsernameViewModel.state.continueEnabled shouldBeEqualTo true createAccountUsernameViewModel.state.loading shouldBeEqualTo false } - @Test - fun `given forbidden character, when entering username, then forbidden character is ignored`() { - every { validateUserHandleUseCase.invoke("a1_") } returns ValidateUserHandleResult.Valid("a1_") - every { validateUserHandleUseCase.invoke("a1_$") } returns - ValidateUserHandleResult.Invalid.InvalidCharacters("a1_", listOf()) - every { validateUserHandleUseCase.invoke("a1_$") } returns ValidateUserHandleResult.Invalid.InvalidCharacters( - "a1_", - "@".toList() - ) - createAccountUsernameViewModel.onUsernameChange(TextFieldValue("a1_")) - createAccountUsernameViewModel.state.username.text shouldBeEqualTo "a1_" - createAccountUsernameViewModel.onUsernameChange(TextFieldValue("a1_$")) - createAccountUsernameViewModel.state.username.text shouldBeEqualTo "a1_" - } - @Test fun `given button is clicked, when setting the username, then show loading`() = runTest { - every { validateUserHandleUseCase.invoke(any()) } returns ValidateUserHandleResult.Valid("abc") - coEvery { setUserHandleUseCase.invoke(any()) } returns SetUserHandleResult.Success + val username = "abc" + val (arrangement, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid(username)) + .withSetUserHandle(SetUserHandleResult.Success) + .arrange() - createAccountUsernameViewModel.onUsernameChange(TextFieldValue("abc")) + createAccountUsernameViewModel.textState.setTextAndPlaceCursorAtEnd("abc") createAccountUsernameViewModel.state.continueEnabled shouldBeEqualTo true createAccountUsernameViewModel.state.loading shouldBeEqualTo false - createAccountUsernameViewModel.onContinue(onSuccess) + createAccountUsernameViewModel.onContinue(arrangement.onSuccess) advanceUntilIdle() createAccountUsernameViewModel.state.continueEnabled shouldBeEqualTo true createAccountUsernameViewModel.state.loading shouldBeEqualTo false @@ -114,25 +89,28 @@ class CreateAccountUsernameViewModelTest { @Test fun `given button is clicked, when request returns Success, then navigate to initial sync screen`() = runTest { val username = "abc" - every { validateUserHandleUseCase.invoke(any()) } returns ValidateUserHandleResult.Valid(username) - coEvery { setUserHandleUseCase.invoke(any()) } returns SetUserHandleResult.Success - createAccountUsernameViewModel.onUsernameChange(TextFieldValue(username)) + val (arrangement, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid(username)) + .withSetUserHandle(SetUserHandleResult.Success) + .arrange() - createAccountUsernameViewModel.onContinue(onSuccess) + createAccountUsernameViewModel.textState.setTextAndPlaceCursorAtEnd(username) + createAccountUsernameViewModel.onContinue(arrangement.onSuccess) advanceUntilIdle() - - // FIXME: change to 1 once the viewModel is fixed - verify(exactly = 2) { validateUserHandleUseCase.invoke(username) } - coVerify(exactly = 1) { setUserHandleUseCase.invoke(username) } - verify(exactly = 1) { onSuccess() } + verify(exactly = 1) { arrangement.validateUserHandleUseCase.invoke(username) } + coVerify(exactly = 1) { arrangement.setUserHandleUseCase.invoke(username) } + verify(exactly = 1) { arrangement.onSuccess() } } @Test fun `given button is clicked, when username is invalid, then UsernameInvalidError is passed`() = runTest { - every { validateUserHandleUseCase.invoke(any()) } returns ValidateUserHandleResult.Invalid.TooShort("a") - coEvery { setUserHandleUseCase.invoke(any()) } returns SetUserHandleResult.Failure.InvalidHandle + val username = "a" + val (arrangement, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Invalid.TooShort(username)) + .withSetUserHandle(SetUserHandleResult.Failure.InvalidHandle) + .arrange() - createAccountUsernameViewModel.onContinue(onSuccess) + createAccountUsernameViewModel.onContinue(arrangement.onSuccess) advanceUntilIdle() createAccountUsernameViewModel.state.error shouldBeInstanceOf HandleUpdateErrorState.TextFieldError.UsernameInvalidError::class @@ -140,10 +118,13 @@ class CreateAccountUsernameViewModelTest { @Test fun `given button is clicked, when request returns HandleExists error, then UsernameTakenError is passed`() = runTest { - every { validateUserHandleUseCase.invoke(any()) } returns ValidateUserHandleResult.Valid("abc") - coEvery { setUserHandleUseCase.invoke(any()) } returns SetUserHandleResult.Failure.HandleExists + val username = "abc" + val (arrangement, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid(username)) + .withSetUserHandle(SetUserHandleResult.Failure.HandleExists) + .arrange() - createAccountUsernameViewModel.onContinue(onSuccess) + createAccountUsernameViewModel.onContinue(arrangement.onSuccess) advanceUntilIdle() createAccountUsernameViewModel.state.error shouldBeInstanceOf HandleUpdateErrorState.TextFieldError.UsernameTakenError::class @@ -152,10 +133,13 @@ class CreateAccountUsernameViewModelTest { @Test fun `given button is clicked, when request returns Generic error, then GenericError is passed`() = runTest { val networkFailure = NetworkFailure.NoNetworkConnection(null) - every { validateUserHandleUseCase.invoke(any()) } returns ValidateUserHandleResult.Valid("abc") - coEvery { setUserHandleUseCase.invoke(any()) } returns SetUserHandleResult.Failure.Generic(networkFailure) + val username = "abc" + val (arrangement, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid(username)) + .withSetUserHandle(SetUserHandleResult.Failure.Generic(networkFailure)) + .arrange() - createAccountUsernameViewModel.onContinue(onSuccess) + createAccountUsernameViewModel.onContinue(arrangement.onSuccess) advanceUntilIdle() createAccountUsernameViewModel.state.error shouldBeInstanceOf HandleUpdateErrorState.DialogError.GenericError::class @@ -165,14 +149,42 @@ class CreateAccountUsernameViewModelTest { @Test fun `given dialog is dismissed, when state error is DialogError, then hide error`() = runTest { - every { validateUserHandleUseCase.invoke(any()) } returns ValidateUserHandleResult.Valid("abc") - coEvery { setUserHandleUseCase.invoke(any()) } returns SetUserHandleResult.Failure.Generic(NetworkFailure.NoNetworkConnection(null)) + val username = "abc" + val (arrangement, createAccountUsernameViewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid(username)) + .withSetUserHandle(SetUserHandleResult.Failure.Generic(NetworkFailure.NoNetworkConnection(null))) + .arrange() - createAccountUsernameViewModel.onContinue(onSuccess) + createAccountUsernameViewModel.onContinue(arrangement.onSuccess) advanceUntilIdle() createAccountUsernameViewModel.state.error shouldBeInstanceOf HandleUpdateErrorState.DialogError.GenericError::class createAccountUsernameViewModel.onErrorDismiss() createAccountUsernameViewModel.state.error shouldBe HandleUpdateErrorState.None } + + private class Arrangement { + @MockK + lateinit var validateUserHandleUseCase: ValidateUserHandleUseCase + + @MockK + lateinit var setUserHandleUseCase: SetUserHandleUseCase + + @MockK(relaxed = true) + lateinit var onSuccess: () -> Unit + + private val viewModel by lazy { CreateAccountUsernameViewModel(validateUserHandleUseCase, setUserHandleUseCase) } + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + mockUri() + } + fun withValidateHandleResult(result: ValidateUserHandleResult, forSpecificHandle: String? = null) = apply { + coEvery { validateUserHandleUseCase(forSpecificHandle?.let { eq(it) } ?: any()) } returns result + } + fun withSetUserHandle(result: SetUserHandleResult) = apply { + coEvery { setUserHandleUseCase.invoke(any()) } returns result + } + fun arrange() = this to viewModel + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt index 80a165abca0..0df6030b1bf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt @@ -18,9 +18,9 @@ package com.wire.android.ui.home.settings.account.displayname -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.TestDispatcherProvider +import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestUser import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.feature.user.DisplayNameUpdateResult @@ -33,13 +33,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class ChangeDisplayNameViewModelTest { @Test @@ -82,55 +80,44 @@ class ChangeDisplayNameViewModelTest { fun `when validating new name, and we have an empty value, then should propagate NameEmptyError`() = runTest { val (_, viewModel) = Arrangement().arrange() - val newValue = TextFieldValue(" ") - viewModel.onNameChange(newValue) + val newValue = " " + viewModel.textState.setTextAndPlaceCursorAtEnd(newValue) assertEquals(DisplayNameState.NameError.TextFieldError.NameEmptyError, viewModel.displayNameState.error) - assertTrue(viewModel.displayNameState.animatedNameError) - assertFalse(viewModel.displayNameState.continueEnabled) + assertEquals(false, viewModel.displayNameState.saveEnabled) } @Test fun `when validating new name, and the value exceeds 64 chars, then should propagate NameExceedLimitError`() = runTest { val (_, viewModel) = Arrangement().arrange() - val over64CharString = TextFieldValue("a9p8fIRG12wvOJ8AKH77UqwHt8lzTTOBlSdIlq1N6xxYBsEIUomLKoRY2IZ1hClOM") - viewModel.onNameChange(over64CharString) + val over64CharString = "a9p8fIRG12wvOJ8AKH77UqwHt8lzTTOBlSdIlq1N6xxYBsEIUomLKoRY2IZ1hClOM" + viewModel.textState.setTextAndPlaceCursorAtEnd(over64CharString) assertEquals(DisplayNameState.NameError.TextFieldError.NameExceedLimitError, viewModel.displayNameState.error) - assertTrue(viewModel.displayNameState.animatedNameError) - assertFalse(viewModel.displayNameState.continueEnabled) + assertEquals(false, viewModel.displayNameState.saveEnabled) } @Test fun `when validating new name, and the value is the same, then should propagate None`() = runTest { val (_, viewModel) = Arrangement().arrange() - viewModel.onNameChange(TextFieldValue("username ")) + val sameValue = "username " + viewModel.textState.setTextAndPlaceCursorAtEnd(sameValue) assertEquals(DisplayNameState.NameError.None, viewModel.displayNameState.error) - assertFalse(viewModel.displayNameState.animatedNameError) - assertFalse(viewModel.displayNameState.continueEnabled) + assertEquals(false, viewModel.displayNameState.saveEnabled) } @Test fun `when validating new name, and the value is valid, then should propagate None and enable 'continue'`() = runTest { val (_, viewModel) = Arrangement().arrange() - viewModel.onNameChange(TextFieldValue("valid new name")) + val newValue = "valid new name" + viewModel.textState.setTextAndPlaceCursorAtEnd(newValue) assertEquals(DisplayNameState.NameError.None, viewModel.displayNameState.error) - assertFalse(viewModel.displayNameState.animatedNameError) - assertTrue(viewModel.displayNameState.continueEnabled) - } - - @Test - fun `when calling onAnimatedError, should emit animatedNameError false to clean state`() = runTest { - val (_, viewModel) = Arrangement().arrange() - - viewModel.onNameErrorAnimated() - - assertFalse(viewModel.displayNameState.animatedNameError) + assertEquals(true, viewModel.displayNameState.saveEnabled) } private class Arrangement { @@ -153,7 +140,6 @@ class ChangeDisplayNameViewModelTest { fun arrange() = this to ChangeDisplayNameViewModel( getSelfUserUseCase, updateDisplayNameUseCase, - TestDispatcherProvider() ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/ChangeEmailViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/ChangeEmailViewModelTest.kt index 0dfd6073971..7c78a13a701 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/ChangeEmailViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/email/ChangeEmailViewModelTest.kt @@ -17,8 +17,9 @@ */ package com.wire.android.ui.home.settings.account.email -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.SnapshotExtension import com.wire.android.config.mockUri import com.wire.android.framework.TestUser import com.wire.android.ui.home.settings.account.email.updateEmail.ChangeEmailState @@ -34,41 +35,41 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import okio.IOException -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class ChangeEmailViewModelTest { @Test fun `given updateEmail returns success with VerificationEmailSent, when updateEmail is called, then navigate to VerifyEmail`() = runTest { val newEmail = "newEmail" - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withNewEmail(newEmail) .withUpdateEmailResult(UpdateEmailUseCase.Result.Success.VerificationEmailSent) .arrange() viewModel.onSaveClicked() - assertTrue { - viewModel.state.flowState is ChangeEmailState.FlowState.Success - && (viewModel.state.flowState as ChangeEmailState.FlowState.Success).newEmail == newEmail + assertInstanceOf(ChangeEmailState.FlowState.Success::class.java, viewModel.state.flowState).also { + assertEquals(newEmail, it.newEmail) } } @Test fun `given updateEmail returns success with NoChange, when updateEmail is called, then navigate back`() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withNewEmail("newEmail") .withUpdateEmailResult(UpdateEmailUseCase.Result.Success.NoChange) .arrange() viewModel.onSaveClicked() - assertTrue { viewModel.state.flowState is ChangeEmailState.FlowState.NoChange } + assertInstanceOf(ChangeEmailState.FlowState.NoChange::class.java, viewModel.state.flowState) } @Test @@ -80,7 +81,7 @@ class ChangeEmailViewModelTest { viewModel.onSaveClicked() - assertTrue { viewModel.state.flowState is ChangeEmailState.FlowState.Error.TextFieldError.AlreadyInUse } + assertInstanceOf(ChangeEmailState.FlowState.Error.TextFieldError.AlreadyInUse::class.java, viewModel.state.flowState) coVerify(exactly = 1) { arrangement.updateEmail(any()) } } @@ -93,7 +94,7 @@ class ChangeEmailViewModelTest { viewModel.onSaveClicked() - assertTrue { viewModel.state.flowState is ChangeEmailState.FlowState.Error.TextFieldError.Generic } + assertInstanceOf(ChangeEmailState.FlowState.Error.TextFieldError.Generic::class.java, viewModel.state.flowState) coVerify(exactly = 1) { arrangement.updateEmail(any()) } } @@ -106,7 +107,7 @@ class ChangeEmailViewModelTest { viewModel.onSaveClicked() - assertTrue { viewModel.state.flowState is ChangeEmailState.FlowState.Error.TextFieldError.InvalidEmail } + assertInstanceOf(ChangeEmailState.FlowState.Error.TextFieldError.InvalidEmail::class.java, viewModel.state.flowState) coVerify(exactly = 1) { arrangement.updateEmail(any()) } } @@ -131,7 +132,7 @@ class ChangeEmailViewModelTest { ) fun withNewEmail(newEmail: String) = apply { - viewModel.state = viewModel.state.copy(email = TextFieldValue(newEmail)) + viewModel.textState.setTextAndPlaceCursorAtEnd(newEmail) } fun withUpdateEmailResult(result: UpdateEmailUseCase.Result) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModelTest.kt index 503aca18b8e..0b76f3fe411 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModelTest.kt @@ -17,8 +17,9 @@ */ package com.wire.android.ui.home.settings.account.handle -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestUser import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState import com.wire.kalium.logic.NetworkFailure @@ -41,12 +42,13 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) class ChangeHandleViewModelTest { @Test fun `given updateHandle returns Success, when onHandleChanged is called, then navigate back`() = runTest { val (arrangement, viewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid("handle")) .withHandle("handle") .withUpdateHandleResult(SetUserHandleResult.Success) .arrange() @@ -62,6 +64,7 @@ class ChangeHandleViewModelTest { @Test fun `given updateHandle returns HandleExists Error, when onSaveClicked is called, then update error state`() = runTest { val (arrangement, viewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid("handle")) .withHandle("handle") .withUpdateHandleResult(SetUserHandleResult.Failure.HandleExists) .arrange() @@ -74,6 +77,7 @@ class ChangeHandleViewModelTest { @Test fun `given updateHandle returns InvalidHandle Error, when onSaveClicked is called, then update error state`() = runTest { val (arrangement, viewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid("handle")) .withHandle("handle") .withUpdateHandleResult(SetUserHandleResult.Failure.InvalidHandle) .arrange() @@ -90,6 +94,7 @@ class ChangeHandleViewModelTest { fun `given updateHandle returns generic Error, when onSaveClicked is called, then update error state`() = runTest { val expectedError = NetworkFailure.NoNetworkConnection(IOException()) val (arrangement, viewModel) = Arrangement() + .withValidateHandleResult(ValidateUserHandleResult.Valid("handle")) .withHandle("handle") .withUpdateHandleResult(SetUserHandleResult.Failure.Generic(expectedError)) .arrange() @@ -108,9 +113,9 @@ class ChangeHandleViewModelTest { .withValidateHandleResult(ValidateUserHandleResult.Valid("handle")) .arrange() - viewModel.onHandleChanged(TextFieldValue("handle")) + viewModel.textState.setTextAndPlaceCursorAtEnd("handle") - assertEquals(viewModel.state.handle, TextFieldValue("handle")) + assertEquals(viewModel.textState.text.toString(), "handle") assertEquals(viewModel.state.error, HandleUpdateErrorState.None) coVerify(exactly = 1) { @@ -129,9 +134,9 @@ class ChangeHandleViewModelTest { ) .arrange() - viewModel.onHandleChanged(TextFieldValue("@handle")) + viewModel.textState.setTextAndPlaceCursorAtEnd("@handle") - assertEquals(viewModel.state.handle, TextFieldValue("@handle")) + assertEquals(viewModel.textState.text.toString(), "@handle") assertEquals(viewModel.state.error, HandleUpdateErrorState.TextFieldError.UsernameInvalidError) coVerify(exactly = 1) { @@ -157,10 +162,10 @@ class ChangeHandleViewModelTest { coEvery { getSelf() } returns flowOf(TestUser.SELF_USER) } - private val viewModel = ChangeHandleViewModel(setHandle, validateHandle, getSelf) + private val viewModel by lazy { ChangeHandleViewModel(setHandle, validateHandle, getSelf) } fun withHandle(handle: String) = apply { - viewModel.state = viewModel.state.copy(handle = TextFieldValue(handle)) + viewModel.textState.setTextAndPlaceCursorAtEnd(handle) } fun withUpdateHandleResult(result: SetUserHandleResult) = apply { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f1e52e832c..9d44af11d22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,8 +45,8 @@ androidx-startup = "1.1.1" # Compose composeBom = "2024.04.01" -compose-foundation = "1.7.0-alpha05" # remove when composeBom contains new stable version of BasicTextField2 -compose-material-android = "1.7.0-alpha05" # remove when composeBom contains new stable version of BasicTextField2 +compose-foundation = "1.7.0-beta01" # remove when composeBom contains new stable version of BasicTextField2 +compose-material-android = "1.7.0-beta01" # remove when composeBom contains new stable version of BasicTextField2 compose-activity = "1.8.2" compose-compiler = "1.5.11" compose-constraint = "1.0.1"