Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: update text input logic to v2 TextFieldState, part 1 [WPB-8779] #3010

Merged
merged 11 commits into from
May 22, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -20,49 +20,43 @@ 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.WireTextField
import com.wire.android.ui.common.textfield.WireTextFieldState
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)
}

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),
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_mention),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
)

Expand All @@ -126,7 +122,7 @@ private fun UsernameContent(
}

@Composable
@Preview
private fun PreviewCreateAccountUsernameScreen() {
UsernameContent(CreateAccountUsernameViewState(), {}, {}, {}, {})
@PreviewMultipleThemes
private fun PreviewCreateAccountUsernameScreen() = WireTheme {
UsernameContent(TextFieldState(), CreateAccountUsernameViewState(), {}, {})
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@

package com.wire.android.ui.authentication.create.username

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.text.input.textAsFlow
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
Expand All @@ -30,47 +33,46 @@ 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.launch
import javax.inject.Inject

@OptIn(ExperimentalFoundationApi::class)
@HiltViewModel
class CreateAccountUsernameViewModel @Inject constructor(
private val validateUserHandleUseCase: ValidateUserHandleUseCase,
private val setUserHandleUseCase: SetUserHandleUseCase
) : ViewModel() {

val textState: TextFieldState = TextFieldState()
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
)
init {
viewModelScope.launch {
textState.textAsFlow().collectLatest { newHandle ->
validateUserHandleUseCase(newHandle.toString()).let { validateResult ->
if (validateResult.handle != newHandle.toString()) {
textState.setTextAndPlaceCursorAtEnd(validateResult.handle)
}
state = when (validateResult) {
is ValidateUserHandleResult.Valid -> state.copy(
error = HandleUpdateErrorState.None,
continueEnabled = !state.loading,
)

is ValidateUserHandleResult.Invalid.TooLong -> state.copy(
username = newText.copy(text = textState.handle),
error = HandleUpdateErrorState.None,
continueEnabled = false,
animateUsernameError = false
)
is ValidateUserHandleResult.Invalid.InvalidCharacters -> state.copy(
error = HandleUpdateErrorState.None,
saleniuk marked this conversation as resolved.
Show resolved Hide resolved
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.TooLong,
is ValidateUserHandleResult.Invalid.TooShort -> state.copy(
error = HandleUpdateErrorState.TextFieldError.UsernameInvalidError,
continueEnabled = false,
)
}
}
}
}
}
Expand All @@ -82,31 +84,16 @@ 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) {
onSuccess()
}
}
}

fun onUsernameErrorAnimated() {
state = state.copy(animateUsernameError = false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 12 additions & 15 deletions app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.compose.foundation.text.input.then
import androidx.compose.runtime.Stable
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 {
Expand All @@ -46,3 +47,37 @@ class MaxLengthDigitsFilter(private val maxLength: Int) : InputTransformation {
@OptIn(ExperimentalFoundationApi::class)
@Stable
fun InputTransformation.maxLengthDigits(maxLength: Int): InputTransformation = this.then(MaxLengthDigitsFilter(maxLength))

@OptIn(ExperimentalFoundationApi::class)
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 transformInput(originalValue: TextFieldCharSequence, valueWithChanges: TextFieldBuffer) {
val newLength = valueWithChanges.length
if (newLength > maxLength) {
valueWithChanges.revertAllChanges()
onIncorrectChangesFound()
}
}
}

@OptIn(ExperimentalFoundationApi::class)
@Stable
fun InputTransformation.maxLengthWithCallback(maxLength: Int, onIncorrectChangesFound: () -> Unit): InputTransformation =
this.then(MaxLengthFilterWithCallback(maxLength, onIncorrectChangesFound))

@OptIn(ExperimentalFoundationApi::class)
class PatternFilterWithCallback(private val pattern: Pattern, private val onIncorrectChangesFound: () -> Unit) : InputTransformation {
override fun transformInput(originalValue: TextFieldCharSequence, valueWithChanges: TextFieldBuffer) {
if (pattern.matcher(valueWithChanges.asCharSequence()).matches()) {
valueWithChanges.revertAllChanges()
onIncorrectChangesFound()
}
}
}

@OptIn(ExperimentalFoundationApi::class)
@Stable
fun InputTransformation.patternWithCallback(pattern: Pattern, onIncorrectChangesFound: () -> Unit): InputTransformation =
this.then(PatternFilterWithCallback(pattern, onIncorrectChangesFound))
Loading
Loading