diff --git a/app/src/main/java/com/getcode/view/components/TextInput.kt b/app/src/main/java/com/getcode/view/components/TextInput.kt new file mode 100644 index 000000000..692307a0e --- /dev/null +++ b/app/src/main/java/com/getcode/view/components/TextInput.kt @@ -0,0 +1,248 @@ +package com.getcode.view.components + +import android.view.ViewTreeObserver +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +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.text2.BasicSecureTextField +import androidx.compose.foundation.text2.BasicTextField2 +import androidx.compose.foundation.text2.input.TextFieldLineLimits +import androidx.compose.foundation.text2.input.TextFieldState +import androidx.compose.foundation.text2.input.TextObfuscationMode +import androidx.compose.foundation.text2.input.textAsFlow +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Text +import androidx.compose.material.TextFieldColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.getcode.theme.BrandLight +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraSmall +import com.getcode.theme.inputColors +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TextInput( + modifier: Modifier = Modifier, + placeholder: String = "", + minLines: Int = 1, + maxLines: Int = 4, + state: TextFieldState, + onStateChanged: () -> Unit = { }, + keyboardActions: KeyboardActions = KeyboardActions(), + keyboardOptions: KeyboardOptions = KeyboardOptions(), + style: TextStyle = CodeTheme.typography.body1, + placeholderStyle: TextStyle = CodeTheme.typography.body1, + shape: Shape = CodeTheme.shapes.extraSmall, + colors: TextFieldColors = inputColors(), + enabled: Boolean = true, + readOnly: Boolean = false, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, + scrollState: ScrollState = rememberScrollState(), +) { + val backgroundColor by colors.backgroundColor(enabled = enabled) + val textColor by colors.textColor(enabled = enabled) + val placeholderColor by colors.placeholderColor(enabled = enabled) + BasicTextField2( + modifier = modifier + .background(backgroundColor, shape) + .defaultMinSize(minHeight = 56.dp), + enabled = enabled, + readOnly = readOnly, + state = state, + cursorBrush = SolidColor(colors.cursorColor(isError = false).value), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = style.copy(color = textColor), + lineLimits = if (maxLines == 1) { + TextFieldLineLimits.SingleLine + } else { + TextFieldLineLimits.MultiLine(minHeightInLines = minLines, maxHeightInLines = maxLines) + }, + decorator = { + DecoratorBox( + state = state, + placeholder = placeholder, + placeholderStyle = placeholderStyle, + placeholderColor = placeholderColor, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + shape = shape, + innerTextField = it + ) + }, + scrollState = scrollState + ) + + LaunchedEffect(Unit) { + state.textAsFlow() + .onEach { onStateChanged() } + .launchIn(this) + } + + val focusManager = LocalFocusManager.current + val keyboardState by keyboardAsState() + LaunchedEffect(keyboardState) { + if (!keyboardState) { + focusManager.clearFocus(true) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SecureTextInput( + modifier: Modifier = Modifier, + placeholder: String = "", + state: TextFieldState, + onStateChanged: () -> Unit = { }, + style: TextStyle = CodeTheme.typography.subtitle1, + placeholderStyle: TextStyle = CodeTheme.typography.subtitle1, + shape: Shape = RectangleShape, + colors: TextFieldColors = inputColors(), + enabled: Boolean = true, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, + scrollState: ScrollState = rememberScrollState(), + textObfuscationMode: TextObfuscationMode = TextObfuscationMode.RevealLastTyped, + onSubmit: () -> Unit = { }, +) { + val backgroundColor by colors.backgroundColor(enabled = enabled) + val textColor by colors.textColor(enabled = enabled) + val placeholderColor by colors.placeholderColor(enabled = enabled) + + BasicSecureTextField( + modifier = modifier + .background(backgroundColor, shape) + .defaultMinSize(minHeight = 56.dp), + enabled = enabled, + state = state, + textStyle = style.copy(color = textColor), + textObfuscationMode = textObfuscationMode, + onSubmit = { + if (it == ImeAction.Go) { + onSubmit() + return@BasicSecureTextField true + } + false + }, + decorator = { + DecoratorBox( + state = state, + placeholder = placeholder, + placeholderStyle = placeholderStyle, + placeholderColor = placeholderColor, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + shape = shape, + innerTextField = it + ) + }, + scrollState = scrollState + ) + + LaunchedEffect(Unit) { + state.textAsFlow() + .onEach { onStateChanged() } + .launchIn(this) + } + + val focusManager = LocalFocusManager.current + val keyboardState by keyboardAsState() + LaunchedEffect(keyboardState) { + if (!keyboardState) { + focusManager.clearFocus(true) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DecoratorBox( + state: TextFieldState, + placeholder: String, + placeholderStyle: TextStyle, + placeholderColor: Color, + leadingIcon: (@Composable () -> Unit)?, + trailingIcon: (@Composable () -> Unit)?, + shape: Shape, + innerTextField: @Composable () -> Unit, + + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border( + width = CodeTheme.dimens.border, + color = BrandLight, + shape = shape, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2) + ) { + leadingIcon?.invoke() + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = CodeTheme.dimens.staticGrid.x2), + contentAlignment = Alignment.CenterStart + ) { + innerTextField() + if (state.text.isEmpty() && placeholder.isNotEmpty()) { + Text( + text = placeholder, + style = placeholderStyle.copy(color = placeholderColor), + maxLines = 1, + ) + } + } + trailingIcon?.invoke() + } +} + +@Composable +fun keyboardAsState(): State { + val keyboardState = remember { mutableStateOf(false) } + val view = LocalView.current + val viewTreeObserver = view.viewTreeObserver + DisposableEffect(viewTreeObserver) { + val listener = ViewTreeObserver.OnGlobalLayoutListener { + keyboardState.value = ViewCompat.getRootWindowInsets(view) + ?.isVisible(WindowInsetsCompat.Type.ime()) ?: true + } + viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { viewTreeObserver.removeOnGlobalLayoutListener(listener) } + } + return keyboardState +} \ No newline at end of file diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt index 20c4acaf6..20f5e38d1 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt @@ -1,14 +1,16 @@ package com.getcode.view.main.account.withdraw +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -20,21 +22,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp -import androidx.constraintlayout.compose.ConstraintLayout import com.getcode.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.screens.WithdrawalArgs import com.getcode.theme.BrandLight import com.getcode.theme.CodeTheme -import com.getcode.theme.extraSmall import com.getcode.theme.green -import com.getcode.theme.inputColors +import com.getcode.util.debugBounds import com.getcode.view.components.ButtonState import com.getcode.view.components.CodeButton +import com.getcode.view.components.TextInput +@OptIn(ExperimentalFoundationApi::class) @Composable fun AccountWithdrawAddress( viewModel: AccountWithdrawAddressViewModel, @@ -43,59 +44,33 @@ fun AccountWithdrawAddress( val navigator = LocalCodeNavigator.current val dataState by viewModel.uiFlow.collectAsState() - ConstraintLayout( + Column( modifier = Modifier .fillMaxWidth() .fillMaxHeight() .padding(horizontal = CodeTheme.dimens.inset) .imePadding() ) { - val (topText, addressField, resolveStatus, pasteButton, nextButton) = createRefs() Text( - modifier = Modifier.constrainAs(topText) { - start.linkTo(parent.start) - end.linkTo(parent.end) - }, + modifier = Modifier + .fillMaxWidth(), text = stringResource(R.string.subtitle_whereToWithdrawKin), style = CodeTheme.typography.body1.copy(textAlign = TextAlign.Center), color = BrandLight ) - OutlinedTextField( + TextInput( modifier = Modifier - .constrainAs(addressField) { - top.linkTo(topText.bottom) - } - .padding(top = CodeTheme.dimens.grid.x4) .fillMaxWidth() - .padding(vertical = CodeTheme.dimens.grid.x1), - placeholder = { - Text( - text = stringResource(R.string.subtitle_enterDestinationAddress), - style = CodeTheme.typography.subtitle1.copy( - fontSize = 16.sp, - ) - ) - }, + .padding(vertical = CodeTheme.dimens.grid.x4), + state = dataState.addressText, + maxLines = 1, + placeholder = stringResource(R.string.subtitle_enterDestinationAddress), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - visualTransformation = VisualTransformation.None, - value = dataState.addressText, - onValueChange = { viewModel.setAddress(it) }, - textStyle = CodeTheme.typography.subtitle1.copy( - fontSize = 16.sp, - ), - singleLine = true, - colors = inputColors(), - shape = CodeTheme.shapes.extraSmall ) Row( - modifier = Modifier - .constrainAs(resolveStatus) { - top.linkTo(addressField.bottom) - } - .fillMaxWidth() - .padding(vertical = CodeTheme.dimens.grid.x1), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { dataState.isValid?.let { isValid -> @@ -129,25 +104,19 @@ fun AccountWithdrawAddress( } CodeButton( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.inset) - .constrainAs(pasteButton) { - top.linkTo(resolveStatus.bottom) - }, + modifier = Modifier.fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x2), onClick = { viewModel.pasteAddress() }, enabled = dataState.isPasteEnabled, text = stringResource(R.string.action_pasteFromClipboard), buttonState = ButtonState.Filled, ) + Spacer(modifier = Modifier.weight(1f)) CodeButton( modifier = Modifier .fillMaxWidth() - .padding(bottom = CodeTheme.dimens.inset) - .constrainAs(nextButton) { - bottom.linkTo(parent.bottom) - }, + .padding(bottom = CodeTheme.dimens.inset), onClick = { viewModel.onSubmit(navigator, arguments) }, diff --git a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt index 84c4e5ad6..2aae8be95 100644 --- a/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt +++ b/app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddressViewModel.kt @@ -3,6 +3,11 @@ package com.getcode.view.main.account.withdraw import android.annotation.SuppressLint import android.content.ClipboardManager import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text2.input.TextFieldState +import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.text2.input.textAsFlow +import androidx.lifecycle.viewModelScope import com.getcode.App import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.screens.WithdrawalArgs @@ -20,12 +25,20 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.kin.sdk.base.tools.Base58 import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +@OptIn(ExperimentalFoundationApi::class) data class AccountWithdrawAddressUiModel( - val addressText: String = "", + val addressText: TextFieldState = TextFieldState(""), val isPasteEnabled: Boolean = false, val isNextEnabled: Boolean = false, val isValid: Boolean? = null, @@ -34,6 +47,7 @@ data class AccountWithdrawAddressUiModel( ) @HiltViewModel +@OptIn(ExperimentalFoundationApi::class) class AccountWithdrawAddressViewModel @Inject constructor( private val client: Client, private val clipboard: ClipboardManager, @@ -41,6 +55,15 @@ class AccountWithdrawAddressViewModel @Inject constructor( ) : BaseViewModel(resources) { val uiFlow = MutableStateFlow(AccountWithdrawAddressUiModel()) + + init { + uiFlow.map { it.addressText } + .flatMapLatest { it.textAsFlow() } + .map { it.toString() } + .debounce(300.milliseconds) + .onEach { updated -> setAddress(updated) } + .launchIn(viewModelScope) + } private fun getClipboardValue(): String { return clipboard.primaryClip?.getItemAt(0)?.text?.toString().orEmpty() } @@ -64,33 +87,24 @@ class AccountWithdrawAddressViewModel @Inject constructor( } fun pasteAddress() { - uiFlow.value = uiFlow.value.copy(isPasteEnabled = false) - val addressText = getClipboardValue() if (isAddressValid(addressText)) { setAddress(addressText) } } - fun setAddress(addressText: String) { + private fun setAddress(text: String) { val publicKey: PublicKey? = try { - val decoded = Base58.decode(addressText) + val decoded = Base58.decode(text) val isValid = decoded.size == 32 if (isValid) PublicKey(decoded.toList()) else null } catch (e: Exception) { null } - val isChanged = addressText != uiFlow.value.addressText - if (isChanged) { - uiFlow.value = uiFlow.value.copy( - addressText = addressText.replace("\n", ""), - isValid = null, - isNextEnabled = false - ) - } + uiFlow.value.addressText.setTextAndPlaceCursorAtEnd(text) - if (publicKey != null && isChanged) { + if (publicKey != null) { getDestinationMetaData(publicKey) } }