diff --git a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/extension/ZatoshiExt.kt b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/extension/ZatoshiExt.kt index 1faa47688..8a061c031 100644 --- a/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/extension/ZatoshiExt.kt +++ b/sdk-ext-lib/src/main/java/cash/z/ecc/sdk/extension/ZatoshiExt.kt @@ -2,6 +2,7 @@ package cash.z.ecc.sdk.extension import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString import cash.z.ecc.android.sdk.model.Zatoshi +import kotlin.math.floor private const val DECIMALS_MAX_LONG = 8 private const val DECIMALS_MIN_LONG = 3 @@ -34,6 +35,8 @@ fun Zatoshi.toZecStringAbbreviated(suffix: String): ZecAmountPair { } } +fun Zatoshi.floor(): Zatoshi = Zatoshi(floorRoundBy(value.toDouble(), 2500.0).toLong()) + data class ZecAmountPair( val main: String, val suffix: String @@ -43,3 +46,8 @@ val Zatoshi.Companion.typicalFee: Zatoshi get() = Zatoshi(TYPICAL_FEE) private const val TYPICAL_FEE = 100000L + +private fun floorRoundBy(number: Double, multiple: Double): Double { + require(multiple != 0.0) { "Multiple cannot be zero" } + return floor(number / multiple) * multiple +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateProposalUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateProposalUseCase.kt index 450672977..5635d90f0 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateProposalUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/CreateProposalUseCase.kt @@ -1,6 +1,7 @@ package co.electriccoin.zcash.ui.common.usecase import cash.z.ecc.android.sdk.model.ZecSend +import cash.z.ecc.sdk.extension.floor import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.common.datasource.AccountDataSource import co.electriccoin.zcash.ui.common.model.KeystoneAccount @@ -16,15 +17,16 @@ class CreateProposalUseCase( private val navigationRouter: NavigationRouter ) { @Suppress("TooGenericExceptionCaught") - suspend operator fun invoke(zecSend: ZecSend) { + suspend operator fun invoke(zecSend: ZecSend, floor: Boolean) { + val normalized = if (floor) zecSend.copy(amount = zecSend.amount.floor()) else zecSend try { when (accountDataSource.getSelectedAccount()) { is KeystoneAccount -> { - keystoneProposalRepository.createProposal(zecSend) + keystoneProposalRepository.createProposal(normalized) keystoneProposalRepository.createPCZTFromProposal() } is ZashiAccount -> - zashiProposalRepository.createProposal(zecSend) + zashiProposalRepository.createProposal(normalized) } navigationRouter.forward(ReviewTransaction) } catch (e: Exception) { diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/Request.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/Request.kt index 02667b73c..7f1cde152 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/Request.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/Request.kt @@ -2,6 +2,8 @@ package co.electriccoin.zcash.ui.screen.request.model import android.content.Context import cash.z.ecc.android.sdk.ext.convertUsdToZec +import cash.z.ecc.android.sdk.ext.convertZecToZatoshi +import cash.z.ecc.android.sdk.ext.toZec import cash.z.ecc.android.sdk.ext.toZecString import cash.z.ecc.android.sdk.model.FiatCurrencyConversion import cash.z.ecc.android.sdk.model.Locale @@ -9,6 +11,8 @@ import cash.z.ecc.android.sdk.model.Memo import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.fromZecString import cash.z.ecc.android.sdk.model.toFiatString +import cash.z.ecc.sdk.extension.floor +import cash.z.ecc.sdk.extension.toZecStringFull import co.electriccoin.zcash.ui.screen.request.ext.convertToDouble data class Request( @@ -17,63 +21,41 @@ data class Request( val qrCodeState: QrCodeState, ) -sealed class AmountState( - open val amount: String, - open val currency: RequestCurrency +data class AmountState( + val amount: String, + val currency: RequestCurrency, + val isValid: Boolean? ) { - fun isValid(): Boolean = this is Valid - - abstract fun copyState( - newValue: String = amount, - newCurrency: RequestCurrency = currency - ): AmountState - - fun toZecString(conversion: FiatCurrencyConversion): String = - runCatching { + fun toZecString( + conversion: FiatCurrencyConversion, + ): String { + return runCatching { amount.convertToDouble().convertUsdToZec(conversion.priceOfZec).toZecString() }.getOrElse { "" } - - fun toFiatString( - context: Context, - conversion: FiatCurrencyConversion - ): String = - kotlin - .runCatching { - Zatoshi.fromZecString(context, amount, Locale.getDefault())?.toFiatString( - currencyConversion = conversion, - locale = Locale.getDefault() - ) ?: "" - }.getOrElse { "" } - - data class Valid( - override val amount: String, - override val currency: RequestCurrency - ) : AmountState(amount, currency) { - override fun copyState( - newValue: String, - newCurrency: RequestCurrency - ) = copy(amount = newValue, currency = newCurrency) } - data class Default( - override val amount: String, - override val currency: RequestCurrency - ) : AmountState(amount, currency) { - override fun copyState( - newValue: String, - newCurrency: RequestCurrency - ) = copy(amount = newValue, currency = newCurrency) + fun toZecStringFloored( + conversion: FiatCurrencyConversion, + ): String { + return runCatching { + amount.convertToDouble().convertUsdToZec(conversion.priceOfZec) + .convertZecToZatoshi() + .floor() + .toZecStringFull() + }.getOrElse { "" } } - data class InValid( - override val amount: String, - override val currency: RequestCurrency - ) : AmountState(amount, currency) { - override fun copyState( - newValue: String, - newCurrency: RequestCurrency - ) = copy(amount = newValue, currency = newCurrency) - } + fun toFiatString(context: Context, conversion: FiatCurrencyConversion) = + runCatching { + Zatoshi.fromZecString( + context = context, + zecString = amount, + locale = Locale.getDefault() + )?.toFiatString( + currencyConversion = conversion, + locale = Locale.getDefault() + ) ?: "" + }.getOrElse { "" } } sealed class MemoState( @@ -96,10 +78,7 @@ sealed class MemoState( ) : MemoState(text, byteSize, zecAmount) companion object { - fun new( - memo: String, - amount: String - ): MemoState { + fun new(memo: String, amount: String): MemoState { val bytesCount = Memo.countLength(memo) return if (bytesCount > Memo.MAX_MEMO_LENGTH_BYTES) { InValid(memo, bytesCount, amount) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestCurrency.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestCurrency.kt index 4e3fe0820..9a56f23db 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestCurrency.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestCurrency.kt @@ -1,7 +1,3 @@ package co.electriccoin.zcash.ui.screen.request.model -sealed class RequestCurrency { - data object Zec : RequestCurrency() - - data object Fiat : RequestCurrency() -} +enum class RequestCurrency { ZEC, FIAT } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestAmountView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestAmountView.kt index c68e30ea4..1006dcb05 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestAmountView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestAmountView.kt @@ -63,16 +63,16 @@ internal fun RequestAmountView( when (state.exchangeRateState) { is ExchangeRateState.Data -> { - if (state.request.amountState.currency == RequestCurrency.Zec) { + if (state.request.amountState.currency == RequestCurrency.ZEC) { RequestAmountWithMainZecView( state = state, - onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.Fiat) }, + onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.FIAT) }, modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular) ) } else { RequestAmountWithMainFiatView( state = state, - onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.Zec) }, + onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.ZEC) }, modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular) ) } @@ -417,7 +417,7 @@ private fun InvalidAmountView( .fillMaxWidth() .requiredHeight(48.dp) ) { - if (amountState is AmountState.InValid) { + if (amountState.isValid == false) { Image( painter = painterResource(id = R.drawable.ic_alert_outline), contentDescription = null diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestView.kt index d25ab59cf..2896274e7 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestView.kt @@ -65,7 +65,7 @@ private fun RequestPreview() = RequestState.Amount( request = Request( - amountState = AmountState.Valid("2.25", RequestCurrency.Zec), + amountState = AmountState("2.25", RequestCurrency.ZEC, true), memoState = MemoState.Valid("", 0, "2.25"), qrCodeState = QrCodeState( @@ -143,7 +143,7 @@ private fun RequestBottomBar( ZashiButton( text = stringResource(id = R.string.request_amount_btn), onClick = state.onDone, - enabled = state.request.amountState.isValid(), + enabled = state.request.amountState.isValid == true, modifier = Modifier .fillMaxWidth() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/viewmodel/RequestViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/viewmodel/RequestViewModel.kt index 270a2e09e..177d9a05b 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/viewmodel/RequestViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/viewmodel/RequestViewModel.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Suppress("TooManyFunctions") @@ -68,7 +69,11 @@ class RequestViewModel( internal val request = MutableStateFlow( Request( - amountState = AmountState.Default(defaultAmount, RequestCurrency.Zec), + amountState = AmountState( + amount = defaultAmount, + currency = RequestCurrency.ZEC, + isValid = null + ), memoState = MemoState.Valid(DEFAULT_MEMO, 0, defaultAmount), qrCodeState = QrCodeState(DEFAULT_URI, defaultAmount, DEFAULT_MEMO), ) @@ -94,26 +99,13 @@ class RequestViewModel( monetarySeparators = getMonetarySeparators(), onAmount = { onAmount(resolveExchangeRateValue(exchangeRateUsd), it) }, onBack = { onBack() }, - onDone = { - when (walletAddress) { - is WalletAddress.Transparent -> { - onAmountAndMemoDone( - walletAddress.address, - zip321BuildUriUseCase, - resolveExchangeRateValue(exchangeRateUsd) - ) - } - is WalletAddress.Unified, is WalletAddress.Sapling -> { - onAmountDone(resolveExchangeRateValue(exchangeRateUsd)) - } - else -> error("Unexpected address type") - } - }, + onDone = { onNextClick(walletAddress, zip321BuildUriUseCase, exchangeRateUsd) }, onSwitch = { onSwitch(resolveExchangeRateValue(exchangeRateUsd), it) }, request = request, zcashCurrency = getZcashCurrency(), ) } + RequestStage.MEMO -> { RequestState.Memo( icon = @@ -129,6 +121,7 @@ class RequestViewModel( zcashCurrency = getZcashCurrency(), ) } + RequestStage.QR_CODE -> { RequestState.QrCode( icon = @@ -136,6 +129,7 @@ class RequestViewModel( is KeystoneAccount -> co.electriccoin.zcash.ui.design.R.drawable .ic_item_keystone_qr + is ZashiAccount -> R.drawable.logo_zec_fill_stroke }, fullScreenIcon = @@ -143,6 +137,7 @@ class RequestViewModel( is KeystoneAccount -> co.electriccoin.zcash.ui.design.R.drawable .ic_item_keystone_qr_white + is ZashiAccount -> R.drawable.logo_zec_fill_stroke_white }, walletAddress = walletAddress, @@ -169,6 +164,25 @@ class RequestViewModel( val shareResultCommand = MutableSharedFlow() + private fun onNextClick( + walletAddress: WalletAddress, + zip321BuildUriUseCase: Zip321BuildUriUseCase, + exchangeRateUsd: ExchangeRateState + ) { + when (walletAddress) { + is WalletAddress.Transparent -> onAmountAndMemoDone( + walletAddress.address, + zip321BuildUriUseCase, + resolveExchangeRateValue(exchangeRateUsd) + ) + + is WalletAddress.Unified, is WalletAddress.Sapling -> + onAmountDone(resolveExchangeRateValue(exchangeRateUsd)) + + else -> error("Unexpected address type") + } + } + private fun resolveExchangeRateValue(exchangeRateUsd: ExchangeRateState): FiatCurrencyConversion? = when (exchangeRateUsd) { is ExchangeRateState.Data -> { @@ -179,6 +193,7 @@ class RequestViewModel( exchangeRateUsd.currencyConversion } } + else -> { // Should not happen as the conversion rate related use cases should not be available Twig.error { "Unexpected screen state" } @@ -186,53 +201,54 @@ class RequestViewModel( } } - private fun onAmount( - conversion: FiatCurrencyConversion?, - onAmount: OnAmount - ) = viewModelScope.launch { - val newState = - when (onAmount) { - is OnAmount.Number -> { - if (request.value.amountState.amount == defaultAmount) { - // Special case with current value only zero - validateAmountState(conversion, onAmount.number.toString()) - } else { - // Adding new number to the result string - validateAmountState( - conversion, - request.value.amountState.amount + onAmount.number - ) + private fun onAmount(conversion: FiatCurrencyConversion?, onAmount: OnAmount) { + request.update { + val newState = + when (onAmount) { + is OnAmount.Number -> { + if (it.amountState.amount == defaultAmount) { + // Special case with current value only zero + validateAmountState(conversion, onAmount.number.toString()) + } else { + // Adding new number to the result string + validateAmountState( + conversion, + it.amountState.amount + onAmount.number + ) + } + } + + is OnAmount.Delete -> { + if (it.amountState.amount.length == 1) { + // Deleting up to the last character + AmountState( + amount = defaultAmount, + currency = it.amountState.currency, + isValid = null + ) + } else { + validateAmountState( + conversion, + it.amountState.amount.dropLast(1) + ) + } + } + + is OnAmount.Separator -> { + if (it.amountState.amount.contains(onAmount.separator)) { + // Separator already present + validateAmountState(conversion, it.amountState.amount) + } else { + validateAmountState( + conversion, + it.amountState.amount + onAmount.separator + ) + } } } - is OnAmount.Delete -> { - if (request.value.amountState.amount.length == 1) { - // Deleting up to the last character - AmountState.Default(defaultAmount, request.value.amountState.currency) - } else { - validateAmountState( - conversion, - request.value.amountState.amount - .dropLast(1) - ) - } - } - is OnAmount.Separator -> { - if (request.value.amountState.amount - .contains(onAmount.separator) - ) { - // Separator already present - validateAmountState(conversion, request.value.amountState.amount) - } else { - validateAmountState( - conversion, - request.value.amountState.amount + onAmount.separator - ) - } - } - } - request.emit( - request.value.copy(amountState = newState) - ) + + it.copy(amountState = newState) + } } // Validates only zeros and decimal separator @@ -252,31 +268,40 @@ class RequestViewModel( ): AmountState { val newAmount = if (resultAmount.contains(defaultAmountValidationRegex)) { - AmountState.Default( + AmountState( // Check for the max decimals in the default (i.e. 0.000) number, too - if (!resultAmount.contains(allowedNumberFormatValidationRegex)) { + amount = if (!resultAmount.contains(allowedNumberFormatValidationRegex)) { request.value.amountState.amount } else { resultAmount }, - request.value.amountState.currency + currency = request.value.amountState.currency, + isValid = null ) } else if (!resultAmount.contains(allowedNumberFormatValidationRegex)) { - AmountState.Valid(request.value.amountState.amount, request.value.amountState.currency) + AmountState( + amount = request.value.amountState.amount, + currency = request.value.amountState.currency, + isValid = true + ) } else { - AmountState.Valid(resultAmount, request.value.amountState.currency) + AmountState( + amount = resultAmount, + currency = request.value.amountState.currency, + isValid = true + ) } // Check for max Zcash supply return newAmount.amount.convertToDouble()?.let { currentValue -> val zecValue = - if (newAmount.currency == RequestCurrency.Fiat && conversion != null) { + if (newAmount.currency == RequestCurrency.FIAT && conversion != null) { currentValue / conversion.priceOfZec } else { currentValue } if (zecValue > MAX_ZCASH_SUPPLY) { - newAmount.copyState(request.value.amountState.amount) + newAmount.copy(amount = request.value.amountState.amount) } else { newAmount } @@ -299,89 +324,81 @@ class RequestViewModel( } } - internal fun onBack() = - viewModelScope.launch { - when (stage.value) { - RequestStage.AMOUNT -> { - navigationRouter.back() - } - RequestStage.MEMO -> { - stage.emit(RequestStage.AMOUNT) - } - RequestStage.QR_CODE -> { - when (ReceiveAddressType.fromOrdinal(addressTypeOrdinal)) { - ReceiveAddressType.Transparent -> { - stage.emit(RequestStage.AMOUNT) - } - ReceiveAddressType.Unified, ReceiveAddressType.Sapling -> { - stage.emit(RequestStage.MEMO) - } - } - } + internal fun onBack() { + when (stage.value) { + RequestStage.AMOUNT -> navigationRouter.back() + + RequestStage.MEMO -> stage.update { RequestStage.AMOUNT } + + RequestStage.QR_CODE -> when (ReceiveAddressType.fromOrdinal(addressTypeOrdinal)) { + ReceiveAddressType.Transparent -> stage.update { RequestStage.AMOUNT } + + ReceiveAddressType.Unified, ReceiveAddressType.Sapling -> stage.update { RequestStage.MEMO } } } + } private fun onClose() = navigationRouter.back() - private fun onAmountDone(conversion: FiatCurrencyConversion?) = - viewModelScope.launch { + private fun onAmountDone(conversion: FiatCurrencyConversion?) { + request.update { val memoAmount = - when (request.value.amountState.currency) { - RequestCurrency.Fiat -> + when (it.amountState.currency) { + RequestCurrency.FIAT -> if (conversion != null) { - request.value.amountState.toZecString(conversion) + it.amountState.toZecStringFloored(conversion) } else { Twig.error { "Unexpected screen state" } - request.value.amountState.amount + it.amountState.amount } - RequestCurrency.Zec -> request.value.amountState.amount + RequestCurrency.ZEC -> it.amountState.amount } - request.emit(request.value.copy(memoState = MemoState.new(DEFAULT_MEMO, memoAmount))) - stage.emit(RequestStage.MEMO) - } - private fun onMemoDone( - address: String, - zip321BuildUriUseCase: Zip321BuildUriUseCase - ) = viewModelScope.launch { - request.emit( - request.value.copy( + it.copy(memoState = MemoState.new(DEFAULT_MEMO, memoAmount)) + } + stage.update { RequestStage.MEMO } + } + + private fun onMemoDone(address: String, zip321BuildUriUseCase: Zip321BuildUriUseCase) { + request.update { + it.copy( qrCodeState = QrCodeState( requestUri = createZip321Uri( address = address, - amount = request.value.memoState.zecAmount, - memo = request.value.memoState.text, + amount = it.memoState.zecAmount, + memo = it.memoState.text, zip321BuildUriUseCase = zip321BuildUriUseCase ), - zecAmount = request.value.memoState.zecAmount, - memo = request.value.memoState.text, + zecAmount = it.memoState.zecAmount, + memo = it.memoState.text, ) ) - ) - stage.emit(RequestStage.QR_CODE) + } + stage.update { RequestStage.QR_CODE } } private fun onAmountAndMemoDone( address: String, zip321BuildUriUseCase: Zip321BuildUriUseCase, conversion: FiatCurrencyConversion? - ) = viewModelScope.launch { - val qrCodeAmount = - when (request.value.amountState.currency) { - RequestCurrency.Fiat -> - if (conversion != null) { - request.value.amountState.toZecString(conversion) - } else { - Twig.error { "Unexpected screen state" } - request.value.amountState.amount - } - RequestCurrency.Zec -> request.value.amountState.amount - } - val newRequest = - request.value.copy( + ) { + request.update { + val qrCodeAmount = + when (it.amountState.currency) { + RequestCurrency.FIAT -> + if (conversion != null) { + it.amountState.toZecStringFloored(conversion) + } else { + Twig.error { "Unexpected screen state" } + it.amountState.amount + } + + RequestCurrency.ZEC -> it.amountState.amount + } + it.copy( qrCodeState = QrCodeState( requestUri = @@ -395,49 +412,39 @@ class RequestViewModel( memo = DEFAULT_MEMO, ) ) - request.emit(newRequest) - stage.emit(RequestStage.QR_CODE) + } + + stage.update { RequestStage.QR_CODE } } private fun onSwitch( conversion: FiatCurrencyConversion?, onSwitchTo: RequestCurrency ) = viewModelScope.launch { - if (conversion == null) { - return@launch + if (conversion == null) return@launch + + request.update { + val newAmount = + when (onSwitchTo) { + RequestCurrency.FIAT -> it.amountState.toFiatString( + context = application.applicationContext, + conversion = conversion + ) + + RequestCurrency.ZEC -> it.amountState.toZecString(conversion) + } + + it.copy( + amountState = if (newAmount.contains(defaultAmountValidationRegex)) { + it.amountState.copy(amount = defaultAmount, currency = onSwitchTo) + } else { + it.amountState.copy(amount = newAmount, currency = onSwitchTo) + } + ) } - val newAmount = - when (onSwitchTo) { - is RequestCurrency.Fiat -> { - request.value.amountState.toFiatString( - application.applicationContext, - conversion - ) - } - is RequestCurrency.Zec -> { - request.value.amountState.toZecString( - conversion - ) - } - } - - // Check default value and shrink it to 0 if necessary - val newState = - if (newAmount.contains(defaultAmountValidationRegex)) { - request.value.amountState.copyState(defaultAmount, onSwitchTo) - } else { - request.value.amountState.copyState(newAmount, onSwitchTo) - } - - request.emit( - request.value.copy(amountState = newState) - ) } - private fun onMemo(memoState: MemoState) = - viewModelScope.launch { - request.emit(request.value.copy(memoState = memoState)) - } + private fun onMemo(memoState: MemoState) = request.update { it.copy(memoState = memoState) } private fun createZip321Uri( address: String, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt index d2a434ba9..040498d37 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/AndroidSend.kt @@ -192,7 +192,8 @@ internal fun WrapSend( monetarySeparators = monetarySeparators, value = amountState.value, fiatValue = amountState.fiatValue, - exchangeRateState = exchangeRateState + exchangeRateState = exchangeRateState, + lastFieldChangedByUser = amountState.lastFieldChangedByUser ) } else { AmountState.newFromFiat( @@ -302,6 +303,7 @@ internal fun WrapSend( onCreateZecSend = { newZecSend -> viewModel.onCreateZecSendClick( newZecSend = newZecSend, + amountState = amountState, setSendStage = setSendStage ) }, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt index fd3dca666..8b2e10847 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/SendViewModel.kt @@ -12,6 +12,8 @@ import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs import co.electriccoin.zcash.ui.screen.contact.AddContactArgs +import co.electriccoin.zcash.ui.screen.send.model.AmountField +import co.electriccoin.zcash.ui.screen.send.model.AmountState import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState import co.electriccoin.zcash.ui.screen.send.model.SendAddressBookState import co.electriccoin.zcash.ui.screen.send.model.SendStage @@ -133,10 +135,11 @@ class SendViewModel( @Suppress("TooGenericExceptionCaught") fun onCreateZecSendClick( newZecSend: ZecSend, + amountState: AmountState, setSendStage: (SendStage) -> Unit ) = viewModelScope.launch { try { - createProposal(newZecSend) + createProposal(zecSend = newZecSend, floor = amountState.lastFieldChangedByUser == AmountField.FIAT) } catch (e: Exception) { setSendStage(SendStage.SendFailure(e.cause?.message ?: e.message ?: "")) Twig.error(e) { "Error creating proposal" } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt index dd03a37c3..c4e4b9752 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/model/AmountState.kt @@ -16,16 +16,19 @@ import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState sealed interface AmountState { val value: String val fiatValue: String + val lastFieldChangedByUser: AmountField data class Valid( override val value: String, override val fiatValue: String, - val zatoshi: Zatoshi + override val lastFieldChangedByUser: AmountField, + val zatoshi: Zatoshi, ) : AmountState data class Invalid( override val value: String, - override val fiatValue: String + override val fiatValue: String, + override val lastFieldChangedByUser: AmountField ) : AmountState companion object { @@ -37,11 +40,12 @@ sealed interface AmountState { fiatValue: String, isTransparentOrTextRecipient: Boolean, exchangeRateState: ExchangeRateState, + lastFieldChangedByUser: AmountField = AmountField.ZEC ): AmountState { val isValid = validate(context, monetarySeparators, value) if (!isValid) { - return Invalid(value, if (value.isBlank()) "" else fiatValue) + return Invalid(value, if (value.isBlank()) "" else fiatValue, lastFieldChangedByUser) } val zatoshi = Zatoshi.fromZecString(context, value, Locale.getDefault()) @@ -57,8 +61,9 @@ sealed interface AmountState { // Note that the zero funds sending is supported for sending a memo-only shielded transaction return when { - (zatoshi == null) -> Invalid(value, if (value.isBlank()) "" else fiatValue) - (zatoshi.value == 0L && isTransparentOrTextRecipient) -> Invalid(value, fiatValue) + (zatoshi == null) -> Invalid(value, if (value.isBlank()) "" else fiatValue, lastFieldChangedByUser) + (zatoshi.value == 0L && isTransparentOrTextRecipient) -> + Invalid(value, fiatValue, lastFieldChangedByUser) else -> { Valid( value = value, @@ -71,7 +76,8 @@ sealed interface AmountState { currencyConversion = currencyConversion, locale = Locale.getDefault(), ) - } + }, + lastFieldChangedByUser = lastFieldChangedByUser ) } } @@ -89,7 +95,11 @@ sealed interface AmountState { val isValid = validate(context, monetarySeparators, fiatValue) if (!isValid) { - return Invalid(value = if (fiatValue.isBlank()) "" else value, fiatValue = fiatValue) + return Invalid( + value = if (fiatValue.isBlank()) "" else value, + fiatValue = fiatValue, + lastFieldChangedByUser = AmountField.FIAT + ) } val zatoshi = @@ -101,16 +111,25 @@ sealed interface AmountState { return when { (zatoshi == null) -> { - Invalid(value = if (fiatValue.isBlank()) "" else value, fiatValue = fiatValue) + Invalid( + value = if (fiatValue.isBlank()) "" else value, + fiatValue = fiatValue, + lastFieldChangedByUser = AmountField.FIAT + ) } (zatoshi.value == 0L && isTransparentOrTextRecipient) -> { - Invalid(if (fiatValue.isBlank()) "" else value, fiatValue) + Invalid( + value = if (fiatValue.isBlank()) "" else value, + fiatValue = fiatValue, + lastFieldChangedByUser = AmountField.FIAT + ) } else -> { Valid( value = zatoshi.toZecString(), zatoshi = zatoshi, - fiatValue = fiatValue + fiatValue = fiatValue, + lastFieldChangedByUser = AmountField.FIAT ) } } @@ -122,6 +141,7 @@ sealed interface AmountState { private const val KEY_VALUE = "value" // $NON-NLS private const val KEY_FIAT_VALUE = "fiat_value" // $NON-NLS private const val KEY_ZATOSHI = "zatoshi" // $NON-NLS + private const val KEY_LAST_FIELD_CHANGED_BY_USER = "last_field_changed_by_user" // $NON-NLS private fun validate( context: Context, @@ -145,18 +165,22 @@ sealed interface AmountState { val amountString = (it[KEY_VALUE] as String) val fiatAmountString = (it[KEY_FIAT_VALUE] as String) val type = (it[KEY_TYPE] as String) + val lastFieldChangedByUser = + AmountField.valueOf(it[KEY_LAST_FIELD_CHANGED_BY_USER] as String) when (type) { TYPE_VALID -> Valid( value = amountString, fiatValue = fiatAmountString, - zatoshi = Zatoshi(it[KEY_ZATOSHI] as Long) + zatoshi = Zatoshi(it[KEY_ZATOSHI] as Long), + lastFieldChangedByUser = lastFieldChangedByUser ) TYPE_INVALID -> Invalid( value = amountString, - fiatValue = fiatAmountString + fiatValue = fiatAmountString, + lastFieldChangedByUser = lastFieldChangedByUser ) else -> null @@ -178,8 +202,11 @@ sealed interface AmountState { } saverMap[KEY_VALUE] = this.value saverMap[KEY_FIAT_VALUE] = this.fiatValue + saverMap[KEY_LAST_FIELD_CHANGED_BY_USER] = this.lastFieldChangedByUser.name return saverMap } } } + +enum class AmountField { ZEC, FIAT } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt index 2d418b0ba..c711276b7 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/send/view/SendView.kt @@ -78,6 +78,7 @@ import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture import co.electriccoin.zcash.ui.screen.balances.BalanceWidget import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetState import co.electriccoin.zcash.ui.screen.send.SendTag +import co.electriccoin.zcash.ui.screen.send.model.AmountField import co.electriccoin.zcash.ui.screen.send.model.AmountState import co.electriccoin.zcash.ui.screen.send.model.MemoState import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState @@ -104,7 +105,8 @@ private fun PreviewSendForm() { AmountState.Valid( value = ZatoshiFixture.ZATOSHI_LONG.toString(), fiatValue = "", - zatoshi = ZatoshiFixture.new() + zatoshi = ZatoshiFixture.new(), + lastFieldChangedByUser = AmountField.FIAT ), setMemoState = {}, memoState = MemoState.new("Test message "), @@ -143,7 +145,8 @@ private fun SendFormTransparentAddressPreview() { AmountState.Valid( value = ZatoshiFixture.ZATOSHI_LONG.toString(), fiatValue = "", - zatoshi = ZatoshiFixture.new() + zatoshi = ZatoshiFixture.new(), + lastFieldChangedByUser = AmountField.FIAT ), setMemoState = {}, memoState = MemoState.new("Test message"), @@ -692,7 +695,7 @@ fun SendFormAmountTextField( } ) - if (exchangeRateState is ExchangeRateState.Data) { + if (exchangeRateState is ExchangeRateState.Data && exchangeRateState.currencyConversion != null) { Spacer(modifier = Modifier.width(12.dp)) Image( modifier = Modifier.padding(top = 12.dp),