Dust notes

This commit is contained in:
Milan Cerovsky 2025-05-12 12:22:27 +02:00
parent 0d30109a25
commit fe43bcad1c
11 changed files with 269 additions and 242 deletions

View File

@ -2,6 +2,7 @@ package cash.z.ecc.sdk.extension
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import kotlin.math.floor
private const val DECIMALS_MAX_LONG = 8 private const val DECIMALS_MAX_LONG = 8
private const val DECIMALS_MIN_LONG = 3 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( data class ZecAmountPair(
val main: String, val main: String,
val suffix: String val suffix: String
@ -43,3 +46,8 @@ val Zatoshi.Companion.typicalFee: Zatoshi
get() = Zatoshi(TYPICAL_FEE) get() = Zatoshi(TYPICAL_FEE)
private const val TYPICAL_FEE = 100000L 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
}

View File

@ -1,6 +1,7 @@
package co.electriccoin.zcash.ui.common.usecase package co.electriccoin.zcash.ui.common.usecase
import cash.z.ecc.android.sdk.model.ZecSend 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.NavigationRouter
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.KeystoneAccount import co.electriccoin.zcash.ui.common.model.KeystoneAccount
@ -16,15 +17,16 @@ class CreateProposalUseCase(
private val navigationRouter: NavigationRouter private val navigationRouter: NavigationRouter
) { ) {
@Suppress("TooGenericExceptionCaught") @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 { try {
when (accountDataSource.getSelectedAccount()) { when (accountDataSource.getSelectedAccount()) {
is KeystoneAccount -> { is KeystoneAccount -> {
keystoneProposalRepository.createProposal(zecSend) keystoneProposalRepository.createProposal(normalized)
keystoneProposalRepository.createPCZTFromProposal() keystoneProposalRepository.createPCZTFromProposal()
} }
is ZashiAccount -> is ZashiAccount ->
zashiProposalRepository.createProposal(zecSend) zashiProposalRepository.createProposal(normalized)
} }
navigationRouter.forward(ReviewTransaction) navigationRouter.forward(ReviewTransaction)
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -2,6 +2,8 @@ package co.electriccoin.zcash.ui.screen.request.model
import android.content.Context import android.content.Context
import cash.z.ecc.android.sdk.ext.convertUsdToZec 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.ext.toZecString
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.Locale 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.Zatoshi
import cash.z.ecc.android.sdk.model.fromZecString import cash.z.ecc.android.sdk.model.fromZecString
import cash.z.ecc.android.sdk.model.toFiatString 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 import co.electriccoin.zcash.ui.screen.request.ext.convertToDouble
data class Request( data class Request(
@ -17,63 +21,41 @@ data class Request(
val qrCodeState: QrCodeState, val qrCodeState: QrCodeState,
) )
sealed class AmountState( data class AmountState(
open val amount: String, val amount: String,
open val currency: RequestCurrency val currency: RequestCurrency,
val isValid: Boolean?
) { ) {
fun isValid(): Boolean = this is Valid fun toZecString(
conversion: FiatCurrencyConversion,
abstract fun copyState( ): String {
newValue: String = amount, return runCatching {
newCurrency: RequestCurrency = currency
): AmountState
fun toZecString(conversion: FiatCurrencyConversion): String =
runCatching {
amount.convertToDouble().convertUsdToZec(conversion.priceOfZec).toZecString() amount.convertToDouble().convertUsdToZec(conversion.priceOfZec).toZecString()
}.getOrElse { "" } }.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( fun toZecStringFloored(
override val amount: String, conversion: FiatCurrencyConversion,
override val currency: RequestCurrency ): String {
) : AmountState(amount, currency) { return runCatching {
override fun copyState( amount.convertToDouble().convertUsdToZec(conversion.priceOfZec)
newValue: String, .convertZecToZatoshi()
newCurrency: RequestCurrency .floor()
) = copy(amount = newValue, currency = newCurrency) .toZecStringFull()
}.getOrElse { "" }
} }
data class InValid( fun toFiatString(context: Context, conversion: FiatCurrencyConversion) =
override val amount: String, runCatching {
override val currency: RequestCurrency Zatoshi.fromZecString(
) : AmountState(amount, currency) { context = context,
override fun copyState( zecString = amount,
newValue: String, locale = Locale.getDefault()
newCurrency: RequestCurrency )?.toFiatString(
) = copy(amount = newValue, currency = newCurrency) currencyConversion = conversion,
} locale = Locale.getDefault()
) ?: ""
}.getOrElse { "" }
} }
sealed class MemoState( sealed class MemoState(
@ -96,10 +78,7 @@ sealed class MemoState(
) : MemoState(text, byteSize, zecAmount) ) : MemoState(text, byteSize, zecAmount)
companion object { companion object {
fun new( fun new(memo: String, amount: String): MemoState {
memo: String,
amount: String
): MemoState {
val bytesCount = Memo.countLength(memo) val bytesCount = Memo.countLength(memo)
return if (bytesCount > Memo.MAX_MEMO_LENGTH_BYTES) { return if (bytesCount > Memo.MAX_MEMO_LENGTH_BYTES) {
InValid(memo, bytesCount, amount) InValid(memo, bytesCount, amount)

View File

@ -1,7 +1,3 @@
package co.electriccoin.zcash.ui.screen.request.model package co.electriccoin.zcash.ui.screen.request.model
sealed class RequestCurrency { enum class RequestCurrency { ZEC, FIAT }
data object Zec : RequestCurrency()
data object Fiat : RequestCurrency()
}

View File

@ -63,16 +63,16 @@ internal fun RequestAmountView(
when (state.exchangeRateState) { when (state.exchangeRateState) {
is ExchangeRateState.Data -> { is ExchangeRateState.Data -> {
if (state.request.amountState.currency == RequestCurrency.Zec) { if (state.request.amountState.currency == RequestCurrency.ZEC) {
RequestAmountWithMainZecView( RequestAmountWithMainZecView(
state = state, state = state,
onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.Fiat) }, onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.FIAT) },
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular) modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular)
) )
} else { } else {
RequestAmountWithMainFiatView( RequestAmountWithMainFiatView(
state = state, state = state,
onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.Zec) }, onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.ZEC) },
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular) modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular)
) )
} }
@ -417,7 +417,7 @@ private fun InvalidAmountView(
.fillMaxWidth() .fillMaxWidth()
.requiredHeight(48.dp) .requiredHeight(48.dp)
) { ) {
if (amountState is AmountState.InValid) { if (amountState.isValid == false) {
Image( Image(
painter = painterResource(id = R.drawable.ic_alert_outline), painter = painterResource(id = R.drawable.ic_alert_outline),
contentDescription = null contentDescription = null

View File

@ -65,7 +65,7 @@ private fun RequestPreview() =
RequestState.Amount( RequestState.Amount(
request = request =
Request( Request(
amountState = AmountState.Valid("2.25", RequestCurrency.Zec), amountState = AmountState("2.25", RequestCurrency.ZEC, true),
memoState = MemoState.Valid("", 0, "2.25"), memoState = MemoState.Valid("", 0, "2.25"),
qrCodeState = qrCodeState =
QrCodeState( QrCodeState(
@ -143,7 +143,7 @@ private fun RequestBottomBar(
ZashiButton( ZashiButton(
text = stringResource(id = R.string.request_amount_btn), text = stringResource(id = R.string.request_amount_btn),
onClick = state.onDone, onClick = state.onDone,
enabled = state.request.amountState.isValid(), enabled = state.request.amountState.isValid == true,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
@ -68,7 +69,11 @@ class RequestViewModel(
internal val request = internal val request =
MutableStateFlow( MutableStateFlow(
Request( Request(
amountState = AmountState.Default(defaultAmount, RequestCurrency.Zec), amountState = AmountState(
amount = defaultAmount,
currency = RequestCurrency.ZEC,
isValid = null
),
memoState = MemoState.Valid(DEFAULT_MEMO, 0, defaultAmount), memoState = MemoState.Valid(DEFAULT_MEMO, 0, defaultAmount),
qrCodeState = QrCodeState(DEFAULT_URI, defaultAmount, DEFAULT_MEMO), qrCodeState = QrCodeState(DEFAULT_URI, defaultAmount, DEFAULT_MEMO),
) )
@ -94,26 +99,13 @@ class RequestViewModel(
monetarySeparators = getMonetarySeparators(), monetarySeparators = getMonetarySeparators(),
onAmount = { onAmount(resolveExchangeRateValue(exchangeRateUsd), it) }, onAmount = { onAmount(resolveExchangeRateValue(exchangeRateUsd), it) },
onBack = { onBack() }, onBack = { onBack() },
onDone = { onDone = { onNextClick(walletAddress, zip321BuildUriUseCase, exchangeRateUsd) },
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")
}
},
onSwitch = { onSwitch(resolveExchangeRateValue(exchangeRateUsd), it) }, onSwitch = { onSwitch(resolveExchangeRateValue(exchangeRateUsd), it) },
request = request, request = request,
zcashCurrency = getZcashCurrency(), zcashCurrency = getZcashCurrency(),
) )
} }
RequestStage.MEMO -> { RequestStage.MEMO -> {
RequestState.Memo( RequestState.Memo(
icon = icon =
@ -129,6 +121,7 @@ class RequestViewModel(
zcashCurrency = getZcashCurrency(), zcashCurrency = getZcashCurrency(),
) )
} }
RequestStage.QR_CODE -> { RequestStage.QR_CODE -> {
RequestState.QrCode( RequestState.QrCode(
icon = icon =
@ -136,6 +129,7 @@ class RequestViewModel(
is KeystoneAccount -> is KeystoneAccount ->
co.electriccoin.zcash.ui.design.R.drawable co.electriccoin.zcash.ui.design.R.drawable
.ic_item_keystone_qr .ic_item_keystone_qr
is ZashiAccount -> R.drawable.logo_zec_fill_stroke is ZashiAccount -> R.drawable.logo_zec_fill_stroke
}, },
fullScreenIcon = fullScreenIcon =
@ -143,6 +137,7 @@ class RequestViewModel(
is KeystoneAccount -> is KeystoneAccount ->
co.electriccoin.zcash.ui.design.R.drawable co.electriccoin.zcash.ui.design.R.drawable
.ic_item_keystone_qr_white .ic_item_keystone_qr_white
is ZashiAccount -> R.drawable.logo_zec_fill_stroke_white is ZashiAccount -> R.drawable.logo_zec_fill_stroke_white
}, },
walletAddress = walletAddress, walletAddress = walletAddress,
@ -169,6 +164,25 @@ class RequestViewModel(
val shareResultCommand = MutableSharedFlow<Boolean>() val shareResultCommand = MutableSharedFlow<Boolean>()
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? = private fun resolveExchangeRateValue(exchangeRateUsd: ExchangeRateState): FiatCurrencyConversion? =
when (exchangeRateUsd) { when (exchangeRateUsd) {
is ExchangeRateState.Data -> { is ExchangeRateState.Data -> {
@ -179,6 +193,7 @@ class RequestViewModel(
exchangeRateUsd.currencyConversion exchangeRateUsd.currencyConversion
} }
} }
else -> { else -> {
// Should not happen as the conversion rate related use cases should not be available // Should not happen as the conversion rate related use cases should not be available
Twig.error { "Unexpected screen state" } Twig.error { "Unexpected screen state" }
@ -186,53 +201,54 @@ class RequestViewModel(
} }
} }
private fun onAmount( private fun onAmount(conversion: FiatCurrencyConversion?, onAmount: OnAmount) {
conversion: FiatCurrencyConversion?, request.update {
onAmount: OnAmount val newState =
) = viewModelScope.launch { when (onAmount) {
val newState = is OnAmount.Number -> {
when (onAmount) { if (it.amountState.amount == defaultAmount) {
is OnAmount.Number -> { // Special case with current value only zero
if (request.value.amountState.amount == defaultAmount) { validateAmountState(conversion, onAmount.number.toString())
// Special case with current value only zero } else {
validateAmountState(conversion, onAmount.number.toString()) // Adding new number to the result string
} else { validateAmountState(
// Adding new number to the result string conversion,
validateAmountState( it.amountState.amount + onAmount.number
conversion, )
request.value.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) { it.copy(amountState = newState)
// 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)
)
} }
// Validates only zeros and decimal separator // Validates only zeros and decimal separator
@ -252,31 +268,40 @@ class RequestViewModel(
): AmountState { ): AmountState {
val newAmount = val newAmount =
if (resultAmount.contains(defaultAmountValidationRegex)) { if (resultAmount.contains(defaultAmountValidationRegex)) {
AmountState.Default( AmountState(
// Check for the max decimals in the default (i.e. 0.000) number, too // 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 request.value.amountState.amount
} else { } else {
resultAmount resultAmount
}, },
request.value.amountState.currency currency = request.value.amountState.currency,
isValid = null
) )
} else if (!resultAmount.contains(allowedNumberFormatValidationRegex)) { } 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 { } else {
AmountState.Valid(resultAmount, request.value.amountState.currency) AmountState(
amount = resultAmount,
currency = request.value.amountState.currency,
isValid = true
)
} }
// Check for max Zcash supply // Check for max Zcash supply
return newAmount.amount.convertToDouble()?.let { currentValue -> return newAmount.amount.convertToDouble()?.let { currentValue ->
val zecValue = val zecValue =
if (newAmount.currency == RequestCurrency.Fiat && conversion != null) { if (newAmount.currency == RequestCurrency.FIAT && conversion != null) {
currentValue / conversion.priceOfZec currentValue / conversion.priceOfZec
} else { } else {
currentValue currentValue
} }
if (zecValue > MAX_ZCASH_SUPPLY) { if (zecValue > MAX_ZCASH_SUPPLY) {
newAmount.copyState(request.value.amountState.amount) newAmount.copy(amount = request.value.amountState.amount)
} else { } else {
newAmount newAmount
} }
@ -299,89 +324,81 @@ class RequestViewModel(
} }
} }
internal fun onBack() = internal fun onBack() {
viewModelScope.launch { when (stage.value) {
when (stage.value) { RequestStage.AMOUNT -> navigationRouter.back()
RequestStage.AMOUNT -> {
navigationRouter.back() RequestStage.MEMO -> stage.update { RequestStage.AMOUNT }
}
RequestStage.MEMO -> { RequestStage.QR_CODE -> when (ReceiveAddressType.fromOrdinal(addressTypeOrdinal)) {
stage.emit(RequestStage.AMOUNT) ReceiveAddressType.Transparent -> stage.update { RequestStage.AMOUNT }
}
RequestStage.QR_CODE -> { ReceiveAddressType.Unified, ReceiveAddressType.Sapling -> stage.update { RequestStage.MEMO }
when (ReceiveAddressType.fromOrdinal(addressTypeOrdinal)) {
ReceiveAddressType.Transparent -> {
stage.emit(RequestStage.AMOUNT)
}
ReceiveAddressType.Unified, ReceiveAddressType.Sapling -> {
stage.emit(RequestStage.MEMO)
}
}
}
} }
} }
}
private fun onClose() = navigationRouter.back() private fun onClose() = navigationRouter.back()
private fun onAmountDone(conversion: FiatCurrencyConversion?) = private fun onAmountDone(conversion: FiatCurrencyConversion?) {
viewModelScope.launch { request.update {
val memoAmount = val memoAmount =
when (request.value.amountState.currency) { when (it.amountState.currency) {
RequestCurrency.Fiat -> RequestCurrency.FIAT ->
if (conversion != null) { if (conversion != null) {
request.value.amountState.toZecString(conversion) it.amountState.toZecStringFloored(conversion)
} else { } else {
Twig.error { "Unexpected screen state" } 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( it.copy(memoState = MemoState.new(DEFAULT_MEMO, memoAmount))
address: String, }
zip321BuildUriUseCase: Zip321BuildUriUseCase stage.update { RequestStage.MEMO }
) = viewModelScope.launch { }
request.emit(
request.value.copy( private fun onMemoDone(address: String, zip321BuildUriUseCase: Zip321BuildUriUseCase) {
request.update {
it.copy(
qrCodeState = qrCodeState =
QrCodeState( QrCodeState(
requestUri = requestUri =
createZip321Uri( createZip321Uri(
address = address, address = address,
amount = request.value.memoState.zecAmount, amount = it.memoState.zecAmount,
memo = request.value.memoState.text, memo = it.memoState.text,
zip321BuildUriUseCase = zip321BuildUriUseCase zip321BuildUriUseCase = zip321BuildUriUseCase
), ),
zecAmount = request.value.memoState.zecAmount, zecAmount = it.memoState.zecAmount,
memo = request.value.memoState.text, memo = it.memoState.text,
) )
) )
) }
stage.emit(RequestStage.QR_CODE) stage.update { RequestStage.QR_CODE }
} }
private fun onAmountAndMemoDone( private fun onAmountAndMemoDone(
address: String, address: String,
zip321BuildUriUseCase: Zip321BuildUriUseCase, zip321BuildUriUseCase: Zip321BuildUriUseCase,
conversion: FiatCurrencyConversion? conversion: FiatCurrencyConversion?
) = viewModelScope.launch { ) {
val qrCodeAmount = request.update {
when (request.value.amountState.currency) { val qrCodeAmount =
RequestCurrency.Fiat -> when (it.amountState.currency) {
if (conversion != null) { RequestCurrency.FIAT ->
request.value.amountState.toZecString(conversion) if (conversion != null) {
} else { it.amountState.toZecStringFloored(conversion)
Twig.error { "Unexpected screen state" } } else {
request.value.amountState.amount Twig.error { "Unexpected screen state" }
} it.amountState.amount
RequestCurrency.Zec -> request.value.amountState.amount }
}
val newRequest = RequestCurrency.ZEC -> it.amountState.amount
request.value.copy( }
it.copy(
qrCodeState = qrCodeState =
QrCodeState( QrCodeState(
requestUri = requestUri =
@ -395,49 +412,39 @@ class RequestViewModel(
memo = DEFAULT_MEMO, memo = DEFAULT_MEMO,
) )
) )
request.emit(newRequest) }
stage.emit(RequestStage.QR_CODE)
stage.update { RequestStage.QR_CODE }
} }
private fun onSwitch( private fun onSwitch(
conversion: FiatCurrencyConversion?, conversion: FiatCurrencyConversion?,
onSwitchTo: RequestCurrency onSwitchTo: RequestCurrency
) = viewModelScope.launch { ) = viewModelScope.launch {
if (conversion == null) { if (conversion == null) return@launch
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) = private fun onMemo(memoState: MemoState) = request.update { it.copy(memoState = memoState) }
viewModelScope.launch {
request.emit(request.value.copy(memoState = memoState))
}
private fun createZip321Uri( private fun createZip321Uri(
address: String, address: String,

View File

@ -192,7 +192,8 @@ internal fun WrapSend(
monetarySeparators = monetarySeparators, monetarySeparators = monetarySeparators,
value = amountState.value, value = amountState.value,
fiatValue = amountState.fiatValue, fiatValue = amountState.fiatValue,
exchangeRateState = exchangeRateState exchangeRateState = exchangeRateState,
lastFieldChangedByUser = amountState.lastFieldChangedByUser
) )
} else { } else {
AmountState.newFromFiat( AmountState.newFromFiat(
@ -302,6 +303,7 @@ internal fun WrapSend(
onCreateZecSend = { newZecSend -> onCreateZecSend = { newZecSend ->
viewModel.onCreateZecSendClick( viewModel.onCreateZecSendClick(
newZecSend = newZecSend, newZecSend = newZecSend,
amountState = amountState,
setSendStage = setSendStage setSendStage = setSendStage
) )
}, },

View File

@ -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.common.usecase.ObserveContactPickedUseCase
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs 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.RecipientAddressState
import co.electriccoin.zcash.ui.screen.send.model.SendAddressBookState import co.electriccoin.zcash.ui.screen.send.model.SendAddressBookState
import co.electriccoin.zcash.ui.screen.send.model.SendStage import co.electriccoin.zcash.ui.screen.send.model.SendStage
@ -133,10 +135,11 @@ class SendViewModel(
@Suppress("TooGenericExceptionCaught") @Suppress("TooGenericExceptionCaught")
fun onCreateZecSendClick( fun onCreateZecSendClick(
newZecSend: ZecSend, newZecSend: ZecSend,
amountState: AmountState,
setSendStage: (SendStage) -> Unit setSendStage: (SendStage) -> Unit
) = viewModelScope.launch { ) = viewModelScope.launch {
try { try {
createProposal(newZecSend) createProposal(zecSend = newZecSend, floor = amountState.lastFieldChangedByUser == AmountField.FIAT)
} catch (e: Exception) { } catch (e: Exception) {
setSendStage(SendStage.SendFailure(e.cause?.message ?: e.message ?: "")) setSendStage(SendStage.SendFailure(e.cause?.message ?: e.message ?: ""))
Twig.error(e) { "Error creating proposal" } Twig.error(e) { "Error creating proposal" }

View File

@ -16,16 +16,19 @@ import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
sealed interface AmountState { sealed interface AmountState {
val value: String val value: String
val fiatValue: String val fiatValue: String
val lastFieldChangedByUser: AmountField
data class Valid( data class Valid(
override val value: String, override val value: String,
override val fiatValue: String, override val fiatValue: String,
val zatoshi: Zatoshi override val lastFieldChangedByUser: AmountField,
val zatoshi: Zatoshi,
) : AmountState ) : AmountState
data class Invalid( data class Invalid(
override val value: String, override val value: String,
override val fiatValue: String override val fiatValue: String,
override val lastFieldChangedByUser: AmountField
) : AmountState ) : AmountState
companion object { companion object {
@ -37,11 +40,12 @@ sealed interface AmountState {
fiatValue: String, fiatValue: String,
isTransparentOrTextRecipient: Boolean, isTransparentOrTextRecipient: Boolean,
exchangeRateState: ExchangeRateState, exchangeRateState: ExchangeRateState,
lastFieldChangedByUser: AmountField = AmountField.ZEC
): AmountState { ): AmountState {
val isValid = validate(context, monetarySeparators, value) val isValid = validate(context, monetarySeparators, value)
if (!isValid) { 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()) 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 // Note that the zero funds sending is supported for sending a memo-only shielded transaction
return when { return when {
(zatoshi == null) -> Invalid(value, if (value.isBlank()) "" else fiatValue) (zatoshi == null) -> Invalid(value, if (value.isBlank()) "" else fiatValue, lastFieldChangedByUser)
(zatoshi.value == 0L && isTransparentOrTextRecipient) -> Invalid(value, fiatValue) (zatoshi.value == 0L && isTransparentOrTextRecipient) ->
Invalid(value, fiatValue, lastFieldChangedByUser)
else -> { else -> {
Valid( Valid(
value = value, value = value,
@ -71,7 +76,8 @@ sealed interface AmountState {
currencyConversion = currencyConversion, currencyConversion = currencyConversion,
locale = Locale.getDefault(), locale = Locale.getDefault(),
) )
} },
lastFieldChangedByUser = lastFieldChangedByUser
) )
} }
} }
@ -89,7 +95,11 @@ sealed interface AmountState {
val isValid = validate(context, monetarySeparators, fiatValue) val isValid = validate(context, monetarySeparators, fiatValue)
if (!isValid) { 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 = val zatoshi =
@ -101,16 +111,25 @@ sealed interface AmountState {
return when { return when {
(zatoshi == null) -> { (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) -> { (zatoshi.value == 0L && isTransparentOrTextRecipient) -> {
Invalid(if (fiatValue.isBlank()) "" else value, fiatValue) Invalid(
value = if (fiatValue.isBlank()) "" else value,
fiatValue = fiatValue,
lastFieldChangedByUser = AmountField.FIAT
)
} }
else -> { else -> {
Valid( Valid(
value = zatoshi.toZecString(), value = zatoshi.toZecString(),
zatoshi = zatoshi, 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_VALUE = "value" // $NON-NLS
private const val KEY_FIAT_VALUE = "fiat_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_ZATOSHI = "zatoshi" // $NON-NLS
private const val KEY_LAST_FIELD_CHANGED_BY_USER = "last_field_changed_by_user" // $NON-NLS
private fun validate( private fun validate(
context: Context, context: Context,
@ -145,18 +165,22 @@ sealed interface AmountState {
val amountString = (it[KEY_VALUE] as String) val amountString = (it[KEY_VALUE] as String)
val fiatAmountString = (it[KEY_FIAT_VALUE] as String) val fiatAmountString = (it[KEY_FIAT_VALUE] as String)
val type = (it[KEY_TYPE] as String) val type = (it[KEY_TYPE] as String)
val lastFieldChangedByUser =
AmountField.valueOf(it[KEY_LAST_FIELD_CHANGED_BY_USER] as String)
when (type) { when (type) {
TYPE_VALID -> TYPE_VALID ->
Valid( Valid(
value = amountString, value = amountString,
fiatValue = fiatAmountString, fiatValue = fiatAmountString,
zatoshi = Zatoshi(it[KEY_ZATOSHI] as Long) zatoshi = Zatoshi(it[KEY_ZATOSHI] as Long),
lastFieldChangedByUser = lastFieldChangedByUser
) )
TYPE_INVALID -> TYPE_INVALID ->
Invalid( Invalid(
value = amountString, value = amountString,
fiatValue = fiatAmountString fiatValue = fiatAmountString,
lastFieldChangedByUser = lastFieldChangedByUser
) )
else -> null else -> null
@ -178,8 +202,11 @@ sealed interface AmountState {
} }
saverMap[KEY_VALUE] = this.value saverMap[KEY_VALUE] = this.value
saverMap[KEY_FIAT_VALUE] = this.fiatValue saverMap[KEY_FIAT_VALUE] = this.fiatValue
saverMap[KEY_LAST_FIELD_CHANGED_BY_USER] = this.lastFieldChangedByUser.name
return saverMap return saverMap
} }
} }
} }
enum class AmountField { ZEC, FIAT }

View File

@ -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.BalanceWidget
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetState import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetState
import co.electriccoin.zcash.ui.screen.send.SendTag 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.AmountState
import co.electriccoin.zcash.ui.screen.send.model.MemoState import co.electriccoin.zcash.ui.screen.send.model.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
@ -104,7 +105,8 @@ private fun PreviewSendForm() {
AmountState.Valid( AmountState.Valid(
value = ZatoshiFixture.ZATOSHI_LONG.toString(), value = ZatoshiFixture.ZATOSHI_LONG.toString(),
fiatValue = "", fiatValue = "",
zatoshi = ZatoshiFixture.new() zatoshi = ZatoshiFixture.new(),
lastFieldChangedByUser = AmountField.FIAT
), ),
setMemoState = {}, setMemoState = {},
memoState = MemoState.new("Test message "), memoState = MemoState.new("Test message "),
@ -143,7 +145,8 @@ private fun SendFormTransparentAddressPreview() {
AmountState.Valid( AmountState.Valid(
value = ZatoshiFixture.ZATOSHI_LONG.toString(), value = ZatoshiFixture.ZATOSHI_LONG.toString(),
fiatValue = "", fiatValue = "",
zatoshi = ZatoshiFixture.new() zatoshi = ZatoshiFixture.new(),
lastFieldChangedByUser = AmountField.FIAT
), ),
setMemoState = {}, setMemoState = {},
memoState = MemoState.new("Test message"), 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)) Spacer(modifier = Modifier.width(12.dp))
Image( Image(
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),