diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentScreen.kt index 39b9cc03db1..8b7ca8b6f19 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentScreen.kt @@ -2,8 +2,6 @@ package com.woocommerce.android.ui.woopos.cashpayment import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -21,19 +19,18 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import com.woocommerce.android.R -import com.woocommerce.android.ui.compose.component.NullableCurrencyTextFieldValueMapper -import com.woocommerce.android.ui.payments.changeduecalculator.CurrencyVisualTransformation import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosButton import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosButtonState +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosMoneyInputField import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosToolbar -import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosTypedInputField import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding import com.woocommerce.android.ui.woopos.root.navigation.WooPosNavigationEvent import org.wordpress.android.fluxc.model.WCSettingsModel @@ -94,10 +91,9 @@ private fun Collecting( onCompleteOrderClicked: () -> Unit, ) { ConstraintLayout( - modifier = Modifier.padding(top = 48.dp.toAdaptivePadding()) - .fillMaxWidth() + modifier = Modifier.fillMaxSize() ) { - val (input, total, changeDue, button) = createRefs() + val (input, total, error, changeDue, button) = createRefs() val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -106,66 +102,70 @@ private fun Collecting( keyboardController?.show() } - val totalBarrier = createStartBarrier(total, changeDue) + Text( + text = state.totalText, + style = MaterialTheme.typography.h6, + modifier = Modifier + .constrainAs(total) { + top.linkTo(parent.top, margin = 4.dp) + start.linkTo(parent.start, margin = 64.dp) + } + ) var inputText by remember { mutableStateOf(state.enteredAmount) } + val marginBetweenTotalAndInput = 48.dp.toAdaptivePadding() val standardMargin = 16.dp.toAdaptivePadding() - WooPosTypedInputField( + WooPosMoneyInputField( modifier = Modifier .focusRequester(focusRequester) .constrainAs(input) { - top.linkTo(parent.top) - start.linkTo(parent.start) - end.linkTo(totalBarrier, margin = standardMargin) - width = Dimension.fillToConstraints + top.linkTo(total.bottom, margin = marginBetweenTotalAndInput) + start.linkTo(parent.start, margin = standardMargin) + end.linkTo(parent.end, margin = standardMargin) }, value = inputText, - label = stringResource(R.string.woopos_cash_payment_enter_amount_label), - valueMapper = NullableCurrencyTextFieldValueMapper.create( - decimalSeparator = state.decimalSeparator, - numberOfDecimals = state.numberOfDecimals - ), onValueChange = { onAmountChanged(it) inputText = it }, - visualTransformation = CurrencyVisualTransformation( - state.currencySymbol, - state.currencyPosition - ), - singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal ), - errorMessage = state.errorMessage, - ) - - Text( - text = state.totalText, - style = MaterialTheme.typography.h6, - fontWeight = FontWeight.Bold, - modifier = Modifier - .constrainAs(total) { - top.linkTo(input.top) - bottom.linkTo(input.bottom) - end.linkTo(parent.end, margin = standardMargin) - } + textStyle = MaterialTheme.typography.h4, + currencySymbol = state.currencySymbol, + currencyPosition = state.currencyPosition, + decimalSeparator = state.decimalSeparator, + numberOfDecimals = state.numberOfDecimals, ) + val smallMargin = 8.dp.toAdaptivePadding() Text( text = state.changeDueText, - style = MaterialTheme.typography.subtitle2, + style = MaterialTheme.typography.body1, color = WooPosTheme.colors.warning, fontWeight = FontWeight.Normal, modifier = Modifier .constrainAs(changeDue) { - bottom.linkTo(input.bottom) + top.linkTo(input.bottom, margin = smallMargin) + start.linkTo(parent.start, margin = standardMargin) end.linkTo(parent.end, margin = standardMargin) } ) - val buttonTopMargin = 48.dp.toAdaptivePadding() + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.constrainAs(error) { + top.linkTo(changeDue.bottom, margin = smallMargin) + start.linkTo(parent.start, margin = standardMargin) + end.linkTo(parent.end, margin = standardMargin) + } + ) + } WooPosButton( text = state.button.text, @@ -177,7 +177,7 @@ private fun Collecting( }, modifier = Modifier .constrainAs(button) { - top.linkTo(changeDue.bottom, margin = buttonTopMargin) + top.linkTo(input.bottom, margin = 96.dp) end.linkTo(parent.end, margin = standardMargin) start.linkTo(parent.start, margin = standardMargin) width = Dimension.fillToConstraints @@ -222,9 +222,9 @@ fun WooPosTotalsPaymentCashWithLabelScreenPreview() { state = WooPosCashPaymentState.Collecting( enteredAmount = null, errorMessage = null, - changeDueText = "5$", + changeDueText = "Change Due 5$", total = BigDecimal(10), - totalText = "10$", + totalText = "Total: 10$", currencySymbol = "$", currencyPosition = WCSettingsModel.CurrencyPosition.LEFT, decimalSeparator = ".", @@ -250,9 +250,9 @@ fun WooPosTotalsPaymentCashWithErrorScreenPreview() { state = WooPosCashPaymentState.Collecting( enteredAmount = BigDecimal(500), errorMessage = "Amount must be more or equal to total", - changeDueText = "5$", + changeDueText = "Change Due 5$", total = BigDecimal(10), - totalText = "10$", + totalText = "Total: 10$", currencySymbol = "$", currencyPosition = WCSettingsModel.CurrencyPosition.LEFT, decimalSeparator = ".", diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModel.kt index 76166b3e1e9..c7a08735306 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModel.kt @@ -25,13 +25,16 @@ class WooPosCashPaymentViewModel @Inject constructor( private val _state = savedState.getStateFlow( scope = viewModelScope, initialValue = WooPosCashPaymentState.Initiating, - key = "woo_pos_cash_payment_state" + key = WOO_POS_CASH_PAYMENT_STATE_KEY ) val state: StateFlow = _state init { viewModelScope.launch { + val savedStateValue = savedState.get(WOO_POS_CASH_PAYMENT_STATE_KEY) + if (savedStateValue != null && savedStateValue != WooPosCashPaymentState.Initiating) return@launch + val order = repository.getOrderById(orderId)!! _state.value = WooPosCashPaymentState.Collecting( enteredAmount = null, @@ -74,7 +77,7 @@ class WooPosCashPaymentViewModel @Inject constructor( priceFormat(changeDue) ) } else { - resourceProvider.getString(R.string.woopos_cash_payment_no_chang_due) + "" } _state.value = currentState.copy( @@ -115,4 +118,8 @@ class WooPosCashPaymentViewModel @Inject constructor( } } } + + private companion object { + const val WOO_POS_CASH_PAYMENT_STATE_KEY = "woo_pos_cash_payment_state" + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosInputFields.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosInputFields.kt index f790e2e4876..590e9d3289a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosInputFields.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosInputFields.kt @@ -1,7 +1,6 @@ package com.woocommerce.android.ui.woopos.common.composeui.component import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -15,12 +14,12 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.TextFieldColors -import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,81 +27,142 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.woocommerce.android.ui.compose.component.BigDecimalTextFieldValueMapper -import com.woocommerce.android.ui.compose.component.TextFieldValueMapper -import com.woocommerce.android.ui.compose.component.WCOutlinedTypedTextField +import com.woocommerce.android.ui.compose.component.NullableCurrencyTextFieldValueMapper +import com.woocommerce.android.ui.payments.changeduecalculator.CurrencyVisualTransformation import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme -import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding +import com.woocommerce.android.util.WooLog +import com.woocommerce.android.util.WooLog.T +import org.wordpress.android.fluxc.model.WCSettingsModel import java.math.BigDecimal +import kotlin.text.isEmpty @Composable -fun WooPosTypedInputField( - value: T, - onValueChange: (T) -> Unit, - label: String, - valueMapper: TextFieldValueMapper, +fun WooPosMoneyInputField( + value: BigDecimal?, + onValueChange: (BigDecimal?) -> Unit, + currencySymbol: String, + currencyPosition: WCSettingsModel.CurrencyPosition, + decimalSeparator: String, + numberOfDecimals: Int, + textStyle: TextStyle = MaterialTheme.typography.h6, + textColor: Color = MaterialTheme.colors.onBackground, modifier: Modifier = Modifier, - helperText: String? = null, - enabled: Boolean = true, - readOnly: Boolean = false, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - errorMessage: String? = null, - visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, - singleLine: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - errorBorderColor = Color.Transparent, - focusedLabelColor = MaterialTheme.colors.onBackground.copy(alpha = 0.8f), - ), - placeholderText: String? = null + contentAlignment: Alignment = Alignment.CenterStart, ) { - Column(modifier = modifier) { - WCOutlinedTypedTextField( - value = value, - onValueChange = onValueChange, - label = label, - valueMapper = valueMapper, - helperText = helperText, - enabled = enabled, - readOnly = readOnly, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - isError = errorMessage != null, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - interactionSource = interactionSource, - colors = colors, - placeholderText = placeholderText, + val visualTransformation = remember { + CurrencyVisualTransformation( + currencySymbol = currencySymbol, + currencyPosition = currencyPosition ) + } + + val visualTransformationWithoutCurrency = remember { + CurrencyVisualTransformation( + currencySymbol = "", + currencyPosition = currencyPosition + ) + } + + val valueMapper = NullableCurrencyTextFieldValueMapper.create( + decimalSeparator = decimalSeparator, + numberOfDecimals = numberOfDecimals + ) - val errorTextStyle = MaterialTheme.typography.subtitle2 - if (errorMessage != null) { + var currentValue by remember { + mutableStateOf(value) + } + var textFieldValue by rememberSaveable( + value != currentValue, + stateSaver = TextFieldValue.Saver, + ) { + currentValue = value + mutableStateOf(TextFieldValue(valueMapper.printValue(value))) + } + + var labelWidth by remember { mutableIntStateOf(0) } + + Box( + modifier = modifier.background(Color.Transparent), + contentAlignment = contentAlignment, + ) { + val showLabel = textFieldValue.text.isEmpty() + if (showLabel) { Text( - text = errorMessage, - color = MaterialTheme.colors.error, - fontWeight = FontWeight.Normal, - style = errorTextStyle, - textAlign = TextAlign.Start, - modifier = Modifier - .padding(start = 16.dp.toAdaptivePadding()) + text = visualTransformation.filter(AnnotatedString("0.00")).text.toString(), + style = textStyle.copy(color = textColor.copy(alpha = 0.2f)), + maxLines = 1, + softWrap = false, + modifier = Modifier.onGloballyPositioned { coordinates -> + labelWidth = coordinates.size.width + } ) } + + val density = LocalDensity.current + + val textFieldModifier = if (showLabel) { + Modifier.width(with(density) { labelWidth.toDp() + 4.dp }) + } else { + Modifier.width(IntrinsicSize.Min) + } + + val textFieldColor = if (showLabel) { + textColor.copy(alpha = 0.2f) + } else { + textColor + } + + BasicTextField( + value = textFieldValue, + onValueChange = onValueChange@{ updatedValue -> + if (updatedValue.text == textFieldValue.text) { + textFieldValue = updatedValue + return@onValueChange + } + val transformedText = valueMapper.transformText(textFieldValue.text, updatedValue.text) + runCatching { valueMapper.parseText(transformedText) }.onSuccess { + textFieldValue = TextFieldValue( + text = transformedText, + composition = updatedValue.composition, + selection = TextRange( + (updatedValue.selection.start + transformedText.length - updatedValue.text.length).coerceIn( + 0, + transformedText.length + ) + ) + ) + + if (!valueMapper.equals(currentValue, it)) { + currentValue = it + onValueChange(it) + } + }.onFailure { + WooLog.e(T.POS, "Failed to parse text: $transformedText", it) + } + }, + textStyle = textStyle.copy(color = textFieldColor), + singleLine = true, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, + visualTransformation = VisualTransformation { + if (it.text.isEmpty()) { + visualTransformationWithoutCurrency.filter(it) + } else { + visualTransformation.filter(it) + } + }, + cursorBrush = SolidColor(MaterialTheme.colors.onBackground), + modifier = textFieldModifier, + ) } } @@ -111,18 +171,17 @@ fun WooPosInputField( value: String, onValueChange: (String) -> Unit, label: String = "", + modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.h6, textColor: Color = MaterialTheme.colors.onBackground, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, contentAlignment: Alignment = Alignment.CenterStart, - modifier: Modifier = Modifier, ) { var labelWidth by remember { mutableIntStateOf(0) } Box( - modifier = modifier - .background(Color.Transparent), + modifier = modifier.background(Color.Transparent), contentAlignment = contentAlignment, ) { if (value.isEmpty()) { @@ -131,10 +190,9 @@ fun WooPosInputField( style = textStyle.copy(color = textColor.copy(alpha = 0.2f)), maxLines = 1, softWrap = false, - modifier = Modifier - .onGloballyPositioned { coordinates -> - labelWidth = coordinates.size.width - } + modifier = Modifier.onGloballyPositioned { coordinates -> + labelWidth = coordinates.size.width + } ) } @@ -161,33 +219,38 @@ fun WooPosInputField( @WooPosPreview @Composable -fun WooPosTypedInputFieldPreview() { +fun WooPosMoneyInputFieldPreview() { WooPosTheme { Column(modifier = Modifier.padding(16.dp)) { - WooPosTypedInputField( - value = BigDecimal.TEN, + WooPosMoneyInputField( + value = null, onValueChange = {}, - valueMapper = BigDecimalTextFieldValueMapper.create(true), - label = "Label", + currencySymbol = "$", + currencyPosition = WCSettingsModel.CurrencyPosition.LEFT, + decimalSeparator = ".", + numberOfDecimals = 2, ) Spacer(modifier = Modifier.size(8.dp)) - WooPosTypedInputField( - value = BigDecimal.TEN, + WooPosMoneyInputField( + value = BigDecimal.ZERO, onValueChange = {}, - valueMapper = BigDecimalTextFieldValueMapper.create(true), - label = "", + currencySymbol = "$", + currencyPosition = WCSettingsModel.CurrencyPosition.LEFT, + decimalSeparator = ".", + numberOfDecimals = 2, ) Spacer(modifier = Modifier.size(8.dp)) - WooPosTypedInputField( + WooPosMoneyInputField( value = BigDecimal.TEN, - valueMapper = BigDecimalTextFieldValueMapper.create(true), onValueChange = {}, - label = "", - errorMessage = "Please enter a valid amount", + currencySymbol = "$", + currencyPosition = WCSettingsModel.CurrencyPosition.LEFT, + decimalSeparator = ".", + numberOfDecimals = 2, ) } } diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index e30f810a677..42c3c61b98c 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4340,10 +4340,8 @@ Cash payment Mark payment as complete - Type amount received Total: %s Change due %s - No change due Error trying to process payment. Try again. Send diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModelTest.kt index 8f2296be282..92387042644 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/cashpayment/WooPosCashPaymentViewModelTest.kt @@ -57,8 +57,6 @@ class WooPosCashPaymentViewModelTest { .thenReturn("Complete Order") whenever(resourceProvider.getString(R.string.woopos_cash_payment_change_due, "20.00")) .thenReturn("Change Due: $20.00") - whenever(resourceProvider.getString(R.string.woopos_cash_payment_no_chang_due)) - .thenReturn("No Change Due") val savedStateHandle = SavedStateHandle(mapOf("orderId" to orderId)) @@ -108,11 +106,9 @@ class WooPosCashPaymentViewModelTest { } @Test - fun `given invalid amount less than total, when onUIEvent AmountChanged, then button is disabled and no change due`() = runTest { + fun `given invalid amount less than total, when onUIEvent AmountChanged, then button is disabled`() = runTest { // GIVEN val enteredAmount = BigDecimal("30.00") - whenever(resourceProvider.getString(R.string.woopos_cash_payment_no_chang_due)) - .thenReturn("No Change Due") // WHEN viewModel.onUIEvent( @@ -124,7 +120,7 @@ class WooPosCashPaymentViewModelTest { assertThat(state).isInstanceOf(WooPosCashPaymentState.Collecting::class.java) val collectingState = state as WooPosCashPaymentState.Collecting assertThat(collectingState.enteredAmount).isEqualTo(enteredAmount) - assertThat(collectingState.changeDueText).isEqualTo("No Change Due") + assertThat(collectingState.changeDueText).isEqualTo("") assertThat(collectingState.button.status).isEqualTo(WooPosCashPaymentState.Collecting.Button.Status.DISABLED) }