Exchange Rates implementation

Closes #532
Closes #578
This commit is contained in:
Milan Cerovsky 2024-07-26 09:45:00 +02:00
parent 28ce6b5a08
commit 095234a6cd
25 changed files with 697 additions and 177 deletions

View File

@ -6,6 +6,10 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
## [Unreleased] ## [Unreleased]
### Added
- Balance now also displays USD value
- An option to enter USD amount in Send Transaction screen
## [1.1.4 (700)] - 2024-07-23 ## [1.1.4 (700)] - 2024-07-23
### Added ### Added

View File

@ -9,6 +9,10 @@ directly impact users rather than highlighting other key architectural updates.*
## [Unreleased] ## [Unreleased]
### Added
- Balance now also displays USD value
- An option to enter USD amount in Send Transaction screen
## [1.1.4 (700)] - 2024-07-23 ## [1.1.4 (700)] - 2024-07-23
### Added ### Added

View File

@ -131,7 +131,7 @@ ZCASH_EMULATOR_WTF_API_KEY=
# Optional absolute path to a Zcash SDK checkout. # Optional absolute path to a Zcash SDK checkout.
# When blank, it pulls the SDK from Maven. # When blank, it pulls the SDK from Maven.
# When set, it uses the path for a Gradle included build. Path can either be absolute or relative to the root of this app's Gradle project. # When set, it uses the path for a Gradle included build. Path can either be absolute or relative to the root of this app's Gradle project.
SDK_INCLUDED_BUILD_PATH= SDK_INCLUDED_BUILD_PATH=/Users/milancerovsky/Developer/zcash/zcash-android-wallet-sdk
# When blank, it pulls the BIP-39 library from Maven. # When blank, it pulls the BIP-39 library from Maven.
# When set, it uses the path for a Gradle included build. Path can either be absolute or relative to the root of this app's Gradle project. # When set, it uses the path for a Gradle included build. Path can either be absolute or relative to the root of this app's Gradle project.

View File

@ -4,8 +4,6 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@ -17,7 +15,6 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.MonetarySeparators
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.design.R import co.electriccoin.zcash.ui.design.R
@ -26,7 +23,7 @@ import java.util.Locale
@Preview @Preview
@Composable @Composable
private fun StyledBalanceComposablePreview() { private fun StyledBalancePreview() =
ZcashTheme(forceDarkMode = false) { ZcashTheme(forceDarkMode = false) {
BlankSurface { BlankSurface {
Column { Column {
@ -35,9 +32,16 @@ private fun StyledBalanceComposablePreview() {
isHideBalances = false, isHideBalances = false,
modifier = Modifier modifier = Modifier
) )
}
}
}
Spacer(modifier = Modifier.height(24.dp)) @Preview
@Composable
private fun HiddenStyledBalancePreview() =
ZcashTheme(forceDarkMode = false) {
BlankSurface {
Column {
StyledBalance( StyledBalance(
balanceParts = ZecAmountTriple(main = "1,234.56789012"), balanceParts = ZecAmountTriple(main = "1,234.56789012"),
isHideBalances = true, isHideBalances = true,
@ -46,7 +50,6 @@ private fun StyledBalanceComposablePreview() {
} }
} }
} }
}
/** /**
* This accepts string with balance and displays it in the UI component styled according to the design * This accepts string with balance and displays it in the UI component styled according to the design

View File

@ -123,7 +123,15 @@ class SendViewTestSetup(
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260 // TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
}, },
setAmountState = {}, setAmountState = {},
amountState = AmountState.new(context, monetarySeparators, "", false), amountState =
AmountState.newFromZec(
context = context,
monetarySeparators = monetarySeparators,
value = "",
fiatValue = "",
isTransparentRecipient = false,
fiatCurrencyConversion = null
),
setMemoState = {}, setMemoState = {},
memoState = MemoState.new(""), memoState = MemoState.new(""),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,

View File

@ -0,0 +1,203 @@
package co.electriccoin.zcash.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.model.FiatCurrency
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.toFiatCurrencyState
import co.electriccoin.zcash.ui.common.extension.toKotlinLocale
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.CircularSmallProgressIndicator
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.util.StringResource
import co.electriccoin.zcash.ui.util.getValue
import co.electriccoin.zcash.ui.util.stringRes
import kotlinx.datetime.Clock
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StyledExchangeBalance(
zatoshi: Zatoshi,
exchangeRate: FiatCurrencyResult,
modifier: Modifier = Modifier,
isHideBalances: Boolean = false,
hiddenBalancePlaceholder: StringResource = stringRes(R.string.hide_balance_placeholder),
textColor: Color = Color.Unspecified,
style: TextStyle = ZcashTheme.typography.primary.titleSmall
) {
val currencySymbol = exchangeRate.fiatCurrency.symbol
Row(
modifier =
modifier
.basicMarquee()
.animateContentSize(),
verticalAlignment = Alignment.CenterVertically
) {
when {
exchangeRate is FiatCurrencyResult.Error -> {
// empty view
}
exchangeRate is FiatCurrencyResult.Loading && !isHideBalances -> {
StyledExchangeText(
text = currencySymbol,
textColor = textColor,
style = style
)
CircularSmallProgressIndicator()
}
else -> {
StyledExchangeText(
text =
if (isHideBalances) {
"${currencySymbol}${hiddenBalancePlaceholder.getValue()}"
} else {
when (
val state =
zatoshi.toFiatCurrencyState(
fiatCurrencyResult = exchangeRate,
locale = Locale.current.toKotlinLocale(),
monetarySeparators = MonetarySeparators.current(java.util.Locale.getDefault())
)
) {
is FiatCurrencyConversionRateState.Current -> state.formattedFiatValue
is FiatCurrencyConversionRateState.Stale -> state.formattedFiatValue
FiatCurrencyConversionRateState.Unavailable ->
stringResource(co.electriccoin.zcash.ui.R.string.fiat_currency_conversion_rate_unavailable)
}
},
textColor = textColor,
style = style
)
}
}
}
}
@Composable
private fun StyledExchangeText(
text: String,
modifier: Modifier = Modifier,
textColor: Color,
style: TextStyle
) {
Text(
text = text,
color = textColor,
style = style,
maxLines = 1,
modifier = modifier
)
}
@Composable
private fun StyledExchangeBalancePreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier,
zatoshi = Zatoshi(1),
exchangeRate =
FiatCurrencyResult.Success(
FiatCurrencyConversion(
fiatCurrency = FiatCurrency.USD,
timestamp = Clock.System.now(),
priceOfZec = 25.0
)
)
)
}
}
@Composable
private fun HiddenStyledExchangeBalancePreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = true,
modifier = Modifier,
zatoshi = Zatoshi(1),
exchangeRate =
FiatCurrencyResult.Success(
FiatCurrencyConversion(
fiatCurrency = FiatCurrency.USD,
timestamp = Clock.System.now(),
priceOfZec = 25.0
)
)
)
}
}
@Composable
private fun LoadingStyledExchangeBalancePreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = true,
modifier = Modifier,
zatoshi = Zatoshi(1),
exchangeRate = FiatCurrencyResult.Loading()
)
}
}
@Preview
@Composable
private fun StyledExchangeBalancePreviewLight() =
ZcashTheme(forceDarkMode = false) {
StyledExchangeBalancePreview()
}
@Preview
@Composable
private fun HiddenStyledExchangeBalancePreviewLight() =
ZcashTheme(forceDarkMode = false) {
HiddenStyledExchangeBalancePreview()
}
@Preview
@Composable
private fun LoadingStyledExchangeBalancePreviewLight() =
ZcashTheme(forceDarkMode = false) {
LoadingStyledExchangeBalancePreview()
}
@Preview
@Composable
private fun StyledExchangeBalancePreviewDark() =
ZcashTheme(forceDarkMode = true) {
StyledExchangeBalancePreview()
}
@Preview
@Composable
private fun HiddenStyledExchangeBalancePreviewDark() =
ZcashTheme(forceDarkMode = true) {
HiddenStyledExchangeBalancePreview()
}
@Preview
@Composable
private fun LoadingStyledExchangeBalancePreviewDark() =
ZcashTheme(forceDarkMode = true) {
LoadingStyledExchangeBalancePreview()
}

View File

@ -17,9 +17,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.extension.toZecStringFull import cash.z.ecc.sdk.extension.toZecStringFull
import cash.z.ecc.sdk.type.ZcashCurrency import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.StyledExchangeBalance
import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple
import co.electriccoin.zcash.ui.design.R import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.BlankSurface import co.electriccoin.zcash.ui.design.component.BlankSurface
@ -30,6 +32,7 @@ import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults
import co.electriccoin.zcash.ui.design.component.ZecAmountTriple import co.electriccoin.zcash.ui.design.component.ZecAmountTriple
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.FiatCurrencyResultFixture
@Preview(device = Devices.PIXEL_2) @Preview(device = Devices.PIXEL_2)
@Composable @Composable
@ -44,12 +47,13 @@ private fun BalanceWidgetPreview() {
balanceState = balanceState =
BalanceState.Available( BalanceState.Available(
totalBalance = Zatoshi(1234567891234567L), totalBalance = Zatoshi(1234567891234567L),
spendableBalance = Zatoshi(1234567891234567L) spendableBalance = Zatoshi(1234567891234567L),
exchangeRate = FiatCurrencyResultFixture.new()
), ),
isHideBalances = false, isHideBalances = false,
isReferenceToBalances = true, isReferenceToBalances = true,
onReferenceClick = {}, onReferenceClick = {},
modifier = Modifier modifier = Modifier,
) )
) )
} }
@ -65,11 +69,15 @@ private fun BalanceWidgetNotAvailableYetPreview() {
) { ) {
@Suppress("MagicNumber") @Suppress("MagicNumber")
BalanceWidget( BalanceWidget(
balanceState = BalanceState.Loading(Zatoshi(0L)), balanceState =
BalanceState.Loading(
totalBalance = Zatoshi(value = 0L),
exchangeRate = FiatCurrencyResultFixture.new()
),
isHideBalances = false, isHideBalances = false,
isReferenceToBalances = true, isReferenceToBalances = true,
onReferenceClick = {}, onReferenceClick = {},
modifier = Modifier modifier = Modifier,
) )
} }
} }
@ -84,22 +92,40 @@ private fun BalanceWidgetHiddenAmountPreview() {
) { ) {
@Suppress("MagicNumber") @Suppress("MagicNumber")
BalanceWidget( BalanceWidget(
balanceState = BalanceState.Loading(Zatoshi(0L)), balanceState =
BalanceState.Loading(
totalBalance = Zatoshi(0L),
exchangeRate = FiatCurrencyResultFixture.new()
),
isHideBalances = true, isHideBalances = true,
isReferenceToBalances = true, isReferenceToBalances = true,
onReferenceClick = {}, onReferenceClick = {},
modifier = Modifier modifier = Modifier,
) )
} }
} }
} }
sealed class BalanceState(open val totalBalance: Zatoshi) { sealed interface BalanceState {
data object None : BalanceState(Zatoshi(0L)) val totalBalance: Zatoshi
val exchangeRate: FiatCurrencyResult
data class Loading(override val totalBalance: Zatoshi) : BalanceState(totalBalance) data class None(
override val exchangeRate: FiatCurrencyResult
) : BalanceState {
override val totalBalance: Zatoshi = Zatoshi(0L)
}
data class Available(override val totalBalance: Zatoshi, val spendableBalance: Zatoshi) : BalanceState(totalBalance) data class Loading(
override val totalBalance: Zatoshi,
override val exchangeRate: FiatCurrencyResult
) : BalanceState
data class Available(
override val totalBalance: Zatoshi,
override val exchangeRate: FiatCurrencyResult,
val spendableBalance: Zatoshi
) : BalanceState
} }
@Composable @Composable
@ -123,6 +149,12 @@ fun BalanceWidget(
parts = balanceState.totalBalance.toZecStringFull().asZecAmountTriple() parts = balanceState.totalBalance.toZecStringFull().asZecAmountTriple()
) )
StyledExchangeBalance(
zatoshi = balanceState.totalBalance,
exchangeRate = balanceState.exchangeRate,
isHideBalances = isHideBalances
)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (isReferenceToBalances) { if (isReferenceToBalances) {
Reference( Reference(
@ -151,7 +183,7 @@ fun BalanceWidget(
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny)) Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
when (balanceState) { when (balanceState) {
BalanceState.None, is BalanceState.Loading -> { is BalanceState.None, is BalanceState.Loading -> {
CircularSmallProgressIndicator(color = ZcashTheme.colors.circularProgressBarSmallDark) CircularSmallProgressIndicator(color = ZcashTheme.colors.circularProgressBarSmallDark)
} }

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.common.model
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.PercentDecimal import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.WalletBalance import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
@ -15,6 +16,7 @@ data class WalletSnapshot(
val orchardBalance: WalletBalance, val orchardBalance: WalletBalance,
val saplingBalance: WalletBalance, val saplingBalance: WalletBalance,
val transparentBalance: Zatoshi, val transparentBalance: Zatoshi,
val exchangeRateUsd: FiatCurrencyResult,
val progress: PercentDecimal, val progress: PercentDecimal,
val synchronizerError: SynchronizerError? val synchronizerError: SynchronizerError?
) { ) {

View File

@ -14,7 +14,7 @@ import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.Account import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.FiatCurrency import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.PercentDecimal import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.PersistableWallet import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.TransactionOverview import cash.z.ecc.android.sdk.model.TransactionOverview
@ -95,19 +95,6 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
null null
) )
/**
* A flow of the user's preferred fiat currency.
*/
val preferredFiatCurrency: StateFlow<FiatCurrency?> =
flow<FiatCurrency?> {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(StandardPreferenceKeys.PREFERRED_FIAT_CURRENCY.observe(preferenceProvider))
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
/** /**
* A flow of the wallet block synchronization state. * A flow of the wallet block synchronization state.
*/ */
@ -310,20 +297,22 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
(snapshot.hasChangePending() || snapshot.hasValuePending()) (snapshot.hasChangePending() || snapshot.hasValuePending())
) -> { ) -> {
BalanceState.Loading( BalanceState.Loading(
totalBalance = snapshot.totalBalance() totalBalance = snapshot.totalBalance(),
exchangeRate = snapshot.exchangeRateUsd
) )
} }
else -> { else -> {
BalanceState.Available( BalanceState.Available(
totalBalance = snapshot.totalBalance(), totalBalance = snapshot.totalBalance(),
spendableBalance = snapshot.spendableBalance() spendableBalance = snapshot.spendableBalance(),
exchangeRate = snapshot.exchangeRateUsd
) )
} }
} }
}.stateIn( }.stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
BalanceState.None BalanceState.None(FiatCurrencyResult.Loading())
) )
/** /**
@ -597,24 +586,29 @@ private fun Synchronizer.toWalletSnapshot() =
// 4 // 4
transparentBalance, transparentBalance,
// 5 // 5
progress, exchangeRateUsd,
// 6 // 6
progress,
// 7
toCommonError() toCommonError()
) { flows -> ) { flows ->
val orchardBalance = flows[2] as WalletBalance? val orchardBalance = flows[2] as WalletBalance?
val saplingBalance = flows[3] as WalletBalance? val saplingBalance = flows[3] as WalletBalance?
val transparentBalance = flows[4] as Zatoshi? val transparentBalance = flows[4] as Zatoshi?
val progressPercentDecimal = flows[5] as PercentDecimal @Suppress("UNCHECKED_CAST")
val exchangeRateUsd = flows[5] as FiatCurrencyResult
val progressPercentDecimal = (flows[6] as PercentDecimal)
WalletSnapshot( WalletSnapshot(
flows[0] as Synchronizer.Status, status = flows[0] as Synchronizer.Status,
flows[1] as CompactBlockProcessor.ProcessorInfo, processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)), orchardBalance = orchardBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
saplingBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)), saplingBalance = saplingBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
transparentBalance ?: Zatoshi(0), transparentBalance = transparentBalance ?: Zatoshi(0),
progressPercentDecimal, exchangeRateUsd = exchangeRateUsd,
flows[6] as SynchronizerError? progress = progressPercentDecimal,
synchronizerError = flows[7] as SynchronizerError?
) )
} }

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.fixture package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.compose.BalanceState import co.electriccoin.zcash.ui.common.compose.BalanceState
@ -11,9 +12,11 @@ object BalanceStateFixture {
fun new( fun new(
totalBalance: Zatoshi = TOTAL_BALANCE, totalBalance: Zatoshi = TOTAL_BALANCE,
spendableBalance: Zatoshi = SPENDABLE_BALANCE spendableBalance: Zatoshi = SPENDABLE_BALANCE,
exchangeRate: FiatCurrencyResult = FiatCurrencyResultFixture.new()
) = BalanceState.Available( ) = BalanceState.Available(
totalBalance = totalBalance, totalBalance = totalBalance,
spendableBalance = spendableBalance spendableBalance = spendableBalance,
exchangeRate = exchangeRate
) )
} }

View File

@ -0,0 +1,17 @@
package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.model.FiatCurrency
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import kotlinx.datetime.Clock
object FiatCurrencyResultFixture {
fun new() =
FiatCurrencyResult.Success(
FiatCurrencyConversion(
fiatCurrency = FiatCurrency.USD,
timestamp = Clock.System.now(),
priceOfZec = 25.0
)
)
}

View File

@ -3,6 +3,7 @@ package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.fixture.WalletBalanceFixture import cash.z.ecc.android.sdk.fixture.WalletBalanceFixture
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.PercentDecimal import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.WalletBalance import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
@ -34,12 +35,13 @@ object WalletSnapshotFixture {
progress: PercentDecimal = PROGRESS, progress: PercentDecimal = PROGRESS,
synchronizerError: SynchronizerError? = null synchronizerError: SynchronizerError? = null
) = WalletSnapshot( ) = WalletSnapshot(
status, status = status,
processorInfo, processorInfo = processorInfo,
orchardBalance, orchardBalance = orchardBalance,
saplingBalance, saplingBalance = saplingBalance,
transparentBalance, transparentBalance = transparentBalance,
progress, exchangeRateUsd = FiatCurrencyResult.Loading(),
synchronizerError progress = progress,
synchronizerError = synchronizerError
) )
} }

View File

@ -1,18 +0,0 @@
package co.electriccoin.zcash.ui.preference
import cash.z.ecc.android.sdk.model.FiatCurrency
import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
data class FiatCurrencyPreferenceDefault(
override val key: PreferenceKey
) : PreferenceDefault<FiatCurrency> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
preferenceProvider.getString(key)?.let { FiatCurrency(it) } ?: FiatCurrency("USD")
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: FiatCurrency
) = preferenceProvider.putString(key, newValue.code)
}

View File

@ -35,11 +35,6 @@ object StandardPreferenceKeys {
val IS_RESTORING_INITIAL_WARNING_SEEN = val IS_RESTORING_INITIAL_WARNING_SEEN =
BooleanPreferenceDefault(PreferenceKey("IS_RESTORING_INITIAL_WARNING_SEEN"), false) BooleanPreferenceDefault(PreferenceKey("IS_RESTORING_INITIAL_WARNING_SEEN"), false)
/**
* The fiat currency that the user prefers.
*/
val PREFERRED_FIAT_CURRENCY = FiatCurrencyPreferenceDefault(PreferenceKey("preferred_fiat_currency_code"))
/** /**
* Screens or flows protected by required authentication * Screens or flows protected by required authentication
*/ */

View File

@ -18,6 +18,9 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.model.FiatCurrency
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.BalanceState import co.electriccoin.zcash.ui.common.compose.BalanceState
@ -37,6 +40,7 @@ import co.electriccoin.zcash.ui.screen.account.AccountTag
import co.electriccoin.zcash.ui.screen.account.fixture.TransactionsFixture import co.electriccoin.zcash.ui.screen.account.fixture.TransactionsFixture
import co.electriccoin.zcash.ui.screen.account.model.TransactionUiState import co.electriccoin.zcash.ui.screen.account.model.TransactionUiState
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import kotlinx.datetime.Clock
@Preview("Account No History") @Preview("Account No History")
@Composable @Composable
@ -69,8 +73,16 @@ private fun HistoryListComposablePreview() {
Account( Account(
balanceState = balanceState =
BalanceState.Available( BalanceState.Available(
Zatoshi(123_000_000L), totalBalance = Zatoshi(value = 123_000_000L),
Zatoshi(123_000_000L) spendableBalance = Zatoshi(value = 123_000_000L),
exchangeRate =
FiatCurrencyResult.Success(
FiatCurrencyConversion(
fiatCurrency = FiatCurrency.USD,
timestamp = Clock.System.now(),
priceOfZec = 25.0
)
)
), ),
isHideBalances = false, isHideBalances = false,
goBalances = {}, goBalances = {},
@ -216,7 +228,7 @@ private fun AccountMainContent(
isHideBalances = isHideBalances, isHideBalances = isHideBalances,
modifier = modifier =
Modifier Modifier
.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular) .padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular),
) )
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
@ -252,7 +264,7 @@ private fun BalancesStatus(
balanceState = balanceState, balanceState = balanceState,
isHideBalances = isHideBalances, isHideBalances = isHideBalances,
isReferenceToBalances = true, isReferenceToBalances = true,
onReferenceClick = goBalances onReferenceClick = goBalances,
) )
} }
} }

View File

@ -34,12 +34,9 @@ data class WalletDisplayValues(
var statusText = "" var statusText = ""
var statusAction: StatusAction = StatusAction.None var statusAction: StatusAction = StatusAction.None
// TODO [#578]: Provide Zatoshi -> USD fiat currency formatting
// TODO [#578]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/578
// We'll ideally provide a "fresh" currencyConversion object here
val fiatCurrencyAmountState = val fiatCurrencyAmountState =
walletSnapshot.spendableBalance().toFiatCurrencyState( walletSnapshot.spendableBalance().toFiatCurrencyState(
null, walletSnapshot.exchangeRateUsd,
Locale.current.toKotlinLocale(), Locale.current.toKotlinLocale(),
MonetarySeparators.current(java.util.Locale.getDefault()) MonetarySeparators.current(java.util.Locale.getDefault())
) )

View File

@ -13,6 +13,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
@ -138,24 +139,45 @@ internal fun WrapSend(
rememberSaveable(stateSaver = AmountState.Saver) { rememberSaveable(stateSaver = AmountState.Saver) {
// Default amount state // Default amount state
mutableStateOf( mutableStateOf(
AmountState.new( AmountState.newFromZec(
context = context, context = context,
value = zecSend?.amount?.toZecString() ?: "", value = zecSend?.amount?.toZecString() ?: "",
monetarySeparators = monetarySeparators, monetarySeparators = monetarySeparators,
isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false,
fiatValue = "",
fiatCurrencyConversion =
(walletSnapshot?.exchangeRateUsd as? FiatCurrencyResult.Success)
?.currencyConversion
) )
) )
} }
// New amount state based on the recipient address type (e.g. shielded supports zero funds sending and // New amount state based on the recipient address type (e.g. shielded supports zero funds sending and
// transparent not) // transparent not)
LaunchedEffect(key1 = recipientAddressState) { LaunchedEffect(recipientAddressState, walletSnapshot?.exchangeRateUsd) {
setAmountState( setAmountState(
AmountState.new( if (amountState.value.isNotBlank() || amountState.fiatValue.isBlank()) {
context = context, AmountState.newFromZec(
isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false, context = context,
monetarySeparators = monetarySeparators, isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false,
value = amountState.value monetarySeparators = monetarySeparators,
) value = amountState.value,
fiatValue = amountState.fiatValue,
fiatCurrencyConversion =
(walletSnapshot?.exchangeRateUsd as? FiatCurrencyResult.Success)
?.currencyConversion
)
} else {
AmountState.newFromFiat(
context = context,
isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false,
monetarySeparators = monetarySeparators,
value = amountState.value,
fiatValue = amountState.fiatValue,
fiatCurrencyConversion =
(walletSnapshot?.exchangeRateUsd as? FiatCurrencyResult.Success)
?.currencyConversion
)
}
) )
} }
@ -170,14 +192,28 @@ internal fun WrapSend(
setSendStage(SendStage.Form) setSendStage(SendStage.Form)
setZecSend(null) setZecSend(null)
setRecipientAddressState(RecipientAddressState.new("", null)) setRecipientAddressState(RecipientAddressState.new("", null))
setAmountState(AmountState.new(context, monetarySeparators, "", false)) setAmountState(
AmountState.newFromZec(
context = context,
monetarySeparators = monetarySeparators,
value = "",
fiatValue = "",
isTransparentRecipient = false,
fiatCurrencyConversion =
(walletSnapshot?.exchangeRateUsd as? FiatCurrencyResult.Success)
?.currencyConversion
)
)
setMemoState(MemoState.new("")) setMemoState(MemoState.new(""))
} }
val onBackAction = { val onBackAction = {
when (sendStage) { when (sendStage) {
SendStage.Form -> goBack() SendStage.Form -> goBack()
SendStage.Proposing -> { /* no action - wait until the sending is done */ } SendStage.Proposing -> {
// no action - wait until the sending is done
}
is SendStage.SendFailure -> setSendStage(SendStage.Form) is SendStage.SendFailure -> setSendStage(SendStage.Form)
} }
} }

View File

@ -2,51 +2,102 @@ package co.electriccoin.zcash.ui.screen.send.model
import android.content.Context import android.content.Context
import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.ui.text.intl.Locale
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecStringExt import cash.z.ecc.android.sdk.model.ZecStringExt
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.toZatoshi
import cash.z.ecc.android.sdk.model.toZecString
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.extension.toKotlinLocale
sealed interface AmountState {
val value: String
val fiatValue: String
sealed class AmountState(
open val value: String,
) {
data class Valid( data class Valid(
override val value: String, override val value: String,
override val fiatValue: String,
val zatoshi: Zatoshi val zatoshi: Zatoshi
) : AmountState(value) ) : AmountState
data class Invalid( data class Invalid(override val value: String, override val fiatValue: String) : AmountState
override val value: String,
) : AmountState(value)
companion object { companion object {
fun new( fun newFromZec(
context: Context, context: Context,
monetarySeparators: MonetarySeparators, monetarySeparators: MonetarySeparators,
value: String, value: String,
isTransparentRecipient: Boolean fiatValue: String,
isTransparentRecipient: Boolean,
fiatCurrencyConversion: FiatCurrencyConversion?,
): AmountState { ): AmountState {
// Validate raw input string val isValid = validate(context, monetarySeparators, value)
val validated =
runCatching {
ZecStringExt.filterContinuous(context, monetarySeparators, value)
}.onFailure {
Twig.error(it) { "Failed while filtering raw amount characters" }
}.getOrDefault(false)
if (!validated) { if (!isValid) {
return Invalid(value) return Invalid(value, if (value.isBlank()) "" else fiatValue)
} }
// Convert the input to Zatoshi type-safe amount representation val zatoshi = Zatoshi.fromZecString(context, value, monetarySeparators)
val zatoshi = (Zatoshi.fromZecString(context, value, monetarySeparators))
// 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) (zatoshi == null) -> Invalid(value, if (value.isBlank()) "" else fiatValue)
(zatoshi.value == 0L && isTransparentRecipient) -> Invalid(value) (zatoshi.value == 0L && isTransparentRecipient) -> Invalid(value, fiatValue)
else -> Valid(value, zatoshi) else -> {
Valid(
value = value,
zatoshi = zatoshi,
fiatValue =
if (fiatCurrencyConversion == null) {
fiatValue
} else {
zatoshi.toFiatString(
currencyConversion = fiatCurrencyConversion,
locale = Locale.current.toKotlinLocale(),
monetarySeparators = MonetarySeparators.current(java.util.Locale.getDefault()),
includeSymbols = false
)
}
)
}
}
}
fun newFromFiat(
context: Context,
monetarySeparators: MonetarySeparators,
value: String,
fiatValue: String,
isTransparentRecipient: Boolean,
fiatCurrencyConversion: FiatCurrencyConversion?,
): AmountState {
val isValid = validate(context, monetarySeparators, fiatValue)
if (!isValid) {
return Invalid(value, fiatValue)
}
val zatoshi =
fiatCurrencyConversion?.toZatoshi(
context = context,
value = fiatValue,
monetarySeparators = MonetarySeparators.current(java.util.Locale.getDefault())
)
return when {
(zatoshi == null) -> Invalid(value, fiatValue)
(zatoshi.value == 0L && isTransparentRecipient) -> Invalid(value, fiatValue)
else -> {
Valid(
value = zatoshi.toZecString(),
zatoshi = zatoshi,
fiatValue = fiatValue
)
}
} }
} }
@ -54,22 +105,45 @@ sealed class AmountState(
private const val TYPE_INVALID = "invalid" // $NON-NLS private const val TYPE_INVALID = "invalid" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS private const val KEY_TYPE = "type" // $NON-NLS
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_ZATOSHI = "zatoshi" // $NON-NLS private const val KEY_ZATOSHI = "zatoshi" // $NON-NLS
private fun validate(
context: Context,
monetarySeparators: MonetarySeparators,
value: String
) = runCatching {
ZecStringExt.filterContinuous(context, monetarySeparators, value)
}.onFailure {
Twig.error(it) { "Failed while filtering raw amount characters" }
}.getOrDefault(false)
internal val Saver internal val Saver
get() = get() =
run { run {
mapSaver<AmountState>( mapSaver(
save = { it.toSaverMap() }, save = { it.toSaverMap() },
restore = { restore = {
if (it.isEmpty()) { if (it.isEmpty()) {
null null
} else { } else {
val amountString = (it[KEY_VALUE] as String) val amountString = (it[KEY_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)
when (type) { when (type) {
TYPE_VALID -> Valid(amountString, Zatoshi(it[KEY_ZATOSHI] as Long)) TYPE_VALID ->
TYPE_INVALID -> Invalid(amountString) Valid(
value = amountString,
fiatValue = fiatAmountString,
zatoshi = Zatoshi(it[KEY_ZATOSHI] as Long)
)
TYPE_INVALID ->
Invalid(
value = amountString,
fiatValue = fiatAmountString
)
else -> null else -> null
} }
} }
@ -84,9 +158,11 @@ sealed class AmountState(
saverMap[KEY_TYPE] = TYPE_VALID saverMap[KEY_TYPE] = TYPE_VALID
saverMap[KEY_ZATOSHI] = this.zatoshi.value saverMap[KEY_ZATOSHI] = this.zatoshi.value
} }
is Invalid -> saverMap[KEY_TYPE] = TYPE_INVALID is Invalid -> saverMap[KEY_TYPE] = TYPE_INVALID
} }
saverMap[KEY_VALUE] = this.value saverMap[KEY_VALUE] = this.value
saverMap[KEY_FIAT_VALUE] = this.fiatValue
return saverMap return saverMap
} }

View File

@ -4,6 +4,7 @@ package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
@ -32,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
@ -47,6 +50,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.Memo import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
@ -104,7 +110,12 @@ private fun PreviewSendForm() {
recipientAddressState = RecipientAddressState("invalid_address", AddressType.Invalid()), recipientAddressState = RecipientAddressState("invalid_address", AddressType.Invalid()),
onRecipientAddressChange = {}, onRecipientAddressChange = {},
setAmountState = {}, setAmountState = {},
amountState = AmountState.Valid(ZatoshiFixture.ZATOSHI_LONG.toString(), ZatoshiFixture.new()), amountState =
AmountState.Valid(
value = ZatoshiFixture.ZATOSHI_LONG.toString(),
fiatValue = "",
zatoshi = ZatoshiFixture.new()
),
setMemoState = {}, setMemoState = {},
memoState = MemoState.new("Test message"), memoState = MemoState.new("Test message"),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
@ -135,7 +146,12 @@ private fun SendFormTransparentAddressPreview() {
), ),
onRecipientAddressChange = {}, onRecipientAddressChange = {},
setAmountState = {}, setAmountState = {},
amountState = AmountState.Valid(ZatoshiFixture.ZATOSHI_LONG.toString(), ZatoshiFixture.new()), amountState =
AmountState.Valid(
value = ZatoshiFixture.ZATOSHI_LONG.toString(),
fiatValue = "",
zatoshi = ZatoshiFixture.new()
),
setMemoState = {}, setMemoState = {},
memoState = MemoState.new("Test message"), memoState = MemoState.new("Test message"),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
@ -363,7 +379,7 @@ private fun SendForm(
Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault)) Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault))
SendFormAmountTextField( SendFormAmountTextField(
amountSate = amountState, amountState = amountState,
imeAction = imeAction =
if (recipientAddressState.type == AddressType.Transparent) { if (recipientAddressState.type == AddressType.Transparent) {
ImeAction.Done ImeAction.Done
@ -583,7 +599,7 @@ fun SendFormAddressTextField(
@Suppress("LongParameterList", "LongMethod") @Suppress("LongParameterList", "LongMethod")
@Composable @Composable
fun SendFormAmountTextField( fun SendFormAmountTextField(
amountSate: AmountState, amountState: AmountState,
imeAction: ImeAction, imeAction: ImeAction,
isTransparentRecipient: Boolean, isTransparentRecipient: Boolean,
monetarySeparators: MonetarySeparators, monetarySeparators: MonetarySeparators,
@ -597,9 +613,9 @@ fun SendFormAmountTextField(
val zcashCurrency = ZcashCurrency.getLocalizedName(context) val zcashCurrency = ZcashCurrency.getLocalizedName(context)
val amountError = val amountError =
when (amountSate) { when (amountState) {
is AmountState.Invalid -> { is AmountState.Invalid -> {
if (amountSate.value.isEmpty()) { if (amountState.value.isEmpty()) {
null null
} else { } else {
stringResource(id = R.string.send_amount_invalid) stringResource(id = R.string.send_amount_invalid)
@ -607,7 +623,7 @@ fun SendFormAmountTextField(
} }
is AmountState.Valid -> { is AmountState.Valid -> {
if (walletSnapshot.spendableBalance() < amountSate.zatoshi) { if (walletSnapshot.spendableBalance() < amountState.zatoshi) {
stringResource(id = R.string.send_amount_insufficient_balance) stringResource(id = R.string.send_amount_insufficient_balance)
} else { } else {
null null
@ -629,47 +645,123 @@ fun SendFormAmountTextField(
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
FormTextField( Row {
value = amountSate.value, FormTextField(
onValueChange = { newValue -> textStyle = ZcashTheme.extendedTypography.textFieldValue.copy(fontSize = 14.sp),
setAmountState( value = amountState.value,
AmountState.new( onValueChange = { newValue ->
context = context, setAmountState(
value = newValue, AmountState.newFromZec(
monetarySeparators = monetarySeparators, context = context,
isTransparentRecipient = isTransparentRecipient value = newValue,
monetarySeparators = monetarySeparators,
isTransparentRecipient = isTransparentRecipient,
fiatValue = amountState.fiatValue,
fiatCurrencyConversion =
(walletSnapshot.exchangeRateUsd as? FiatCurrencyResult.Success)
?.currencyConversion
)
) )
) },
}, modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(), error = amountError,
error = amountError, placeholder = {
placeholder = { Text(
Text( text =
text = stringResource(
stringResource( id = R.string.send_amount_hint,
id = R.string.send_amount_hint, zcashCurrency
zcashCurrency ),
), style = ZcashTheme.extendedTypography.textFieldHint,
style = ZcashTheme.extendedTypography.textFieldHint, color = ZcashTheme.colors.textFieldHint
color = ZcashTheme.colors.textFieldHint )
) },
}, keyboardOptions =
keyboardOptions = KeyboardOptions(
KeyboardOptions( keyboardType = KeyboardType.Number,
keyboardType = KeyboardType.Number, imeAction = imeAction
imeAction = imeAction ),
), keyboardActions =
keyboardActions = KeyboardActions(
KeyboardActions( onDone = {
onDone = { focusManager.clearFocus(true)
focusManager.clearFocus(true) },
}, onNext = {
onNext = { focusManager.moveFocus(FocusDirection.Right)
focusManager.moveFocus(FocusDirection.Down) }
} ),
), bringIntoViewRequester = bringIntoViewRequester,
bringIntoViewRequester = bringIntoViewRequester, leadingIcon = {
) Image(
modifier = Modifier.requiredSize(7.dp, 13.dp),
painter = painterResource(R.drawable.ic_send_zashi),
contentDescription = "",
colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor),
)
}
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingMin))
Image(
modifier = Modifier.padding(top = 24.dp),
painter = painterResource(id = R.drawable.ic_send_convert),
contentDescription = "",
colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor),
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingMin))
FormTextField(
textStyle = ZcashTheme.extendedTypography.textFieldValue.copy(fontSize = 14.sp),
value = amountState.fiatValue,
onValueChange = { newValue -> // TODO
setAmountState(
AmountState.newFromFiat(
context = context,
value = amountState.value,
monetarySeparators = monetarySeparators,
isTransparentRecipient = isTransparentRecipient,
fiatValue = newValue,
fiatCurrencyConversion =
(walletSnapshot.exchangeRateUsd as? FiatCurrencyResult.Success)
?.currencyConversion
)
)
},
modifier = Modifier.weight(1f),
placeholder = {
Text(
text =
stringResource(
id = R.string.send_usd_amount_hint,
zcashCurrency
),
style = ZcashTheme.extendedTypography.textFieldHint,
color = ZcashTheme.colors.textFieldHint
)
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = imeAction
),
keyboardActions =
KeyboardActions(
onDone = {
focusManager.clearFocus(true)
},
onNext = {
focusManager.moveFocus(FocusDirection.Down)
}
),
bringIntoViewRequester = bringIntoViewRequester,
leadingIcon = {
Image(
modifier = Modifier.requiredSize(7.dp, 13.dp),
painter = painterResource(R.drawable.ic_usd),
contentDescription = "",
colorFilter = ColorFilter.tint(color = ZcashTheme.colors.secondaryColor),
)
}
)
}
} }
} }

View File

@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import cash.z.ecc.android.sdk.SdkSynchronizer import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.Proposal import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionSubmitResult import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
@ -27,6 +28,7 @@ import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
@ -74,6 +76,8 @@ internal fun MainActivity.WrapSendConfirmation(
val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value
val walletSnapshot = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value
WrapSendConfirmation( WrapSendConfirmation(
activity = this, activity = this,
arguments = arguments, arguments = arguments,
@ -87,6 +91,7 @@ internal fun MainActivity.WrapSendConfirmation(
supportMessage = supportMessage, supportMessage = supportMessage,
synchronizer = synchronizer, synchronizer = synchronizer,
topAppBarSubTitleState = walletState, topAppBarSubTitleState = walletState,
walletSnapshot = walletSnapshot
) )
} }
@ -106,6 +111,7 @@ internal fun WrapSendConfirmation(
supportMessage: SupportInfo?, supportMessage: SupportInfo?,
synchronizer: Synchronizer?, synchronizer: Synchronizer?,
topAppBarSubTitleState: TopAppBarSubTitleState, topAppBarSubTitleState: TopAppBarSubTitleState,
walletSnapshot: WalletSnapshot?
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -206,6 +212,7 @@ internal fun WrapSendConfirmation(
} }
}, },
topAppBarSubTitleState = topAppBarSubTitleState, topAppBarSubTitleState = topAppBarSubTitleState,
exchangeRate = walletSnapshot?.exchangeRateUsd ?: FiatCurrencyResult.Loading()
) )
if (sendFundsAuthentication.value) { if (sendFundsAuthentication.value) {

View File

@ -32,6 +32,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.model.FiatCurrencyResult
import cash.z.ecc.android.sdk.model.FirstClassByteArray import cash.z.ecc.android.sdk.model.FirstClassByteArray
import cash.z.ecc.android.sdk.model.TransactionSubmitResult import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
@ -39,6 +40,7 @@ import cash.z.ecc.sdk.extension.toZecStringFull
import cash.z.ecc.sdk.fixture.MemoFixture import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture import cash.z.ecc.sdk.fixture.ZatoshiFixture
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.StyledExchangeBalance
import co.electriccoin.zcash.ui.common.compose.BalanceWidgetBigLineOnly import co.electriccoin.zcash.ui.common.compose.BalanceWidgetBigLineOnly
import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
@ -83,7 +85,8 @@ private fun SendConfirmationPreview() {
stage = SendConfirmationStage.Confirmation, stage = SendConfirmationStage.Confirmation,
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
onContactSupport = {}, onContactSupport = {},
submissionResults = emptyList<TransactionSubmitResult>().toImmutableList() submissionResults = emptyList<TransactionSubmitResult>().toImmutableList(),
exchangeRate = FiatCurrencyResult.Loading()
) )
} }
} }
@ -106,7 +109,8 @@ private fun SendConfirmationDarkPreview() {
stage = SendConfirmationStage.Confirmation, stage = SendConfirmationStage.Confirmation,
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
onContactSupport = {}, onContactSupport = {},
submissionResults = emptyList<TransactionSubmitResult>().toImmutableList() submissionResults = emptyList<TransactionSubmitResult>().toImmutableList(),
exchangeRate = FiatCurrencyResult.Loading()
) )
} }
} }
@ -129,7 +133,8 @@ private fun SendMultipleErrorPreview() {
stage = SendConfirmationStage.MultipleTrxFailure, stage = SendConfirmationStage.MultipleTrxFailure,
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
onContactSupport = {}, onContactSupport = {},
submissionResults = emptyList<TransactionSubmitResult>().toImmutableList() submissionResults = emptyList<TransactionSubmitResult>().toImmutableList(),
exchangeRate = FiatCurrencyResult.Loading()
) )
} }
} }
@ -152,7 +157,8 @@ private fun SendMultipleErrorDarkPreview() {
stage = SendConfirmationStage.MultipleTrxFailure, stage = SendConfirmationStage.MultipleTrxFailure,
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
onContactSupport = {}, onContactSupport = {},
submissionResults = emptyList<TransactionSubmitResult>().toImmutableList() submissionResults = emptyList<TransactionSubmitResult>().toImmutableList(),
exchangeRate = FiatCurrencyResult.Loading()
) )
} }
} }
@ -171,7 +177,8 @@ private fun PreviewSendConfirmation() {
), ),
onConfirmation = {}, onConfirmation = {},
onBack = {}, onBack = {},
isSending = false isSending = false,
exchangeRate = FiatCurrencyResult.Loading()
) )
} }
} }
@ -244,6 +251,7 @@ fun SendConfirmation(
submissionResults: ImmutableList<TransactionSubmitResult>, submissionResults: ImmutableList<TransactionSubmitResult>,
topAppBarSubTitleState: TopAppBarSubTitleState, topAppBarSubTitleState: TopAppBarSubTitleState,
zecSend: ZecSend, zecSend: ZecSend,
exchangeRate: FiatCurrencyResult
) { ) {
BlankBgScaffold( BlankBgScaffold(
topBar = { topBar = {
@ -269,7 +277,8 @@ fun SendConfirmation(
bottom = paddingValues.calculateBottomPadding(), bottom = paddingValues.calculateBottomPadding(),
start = ZcashTheme.dimens.screenHorizontalSpacingRegular, start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular end = ZcashTheme.dimens.screenHorizontalSpacingRegular
) ),
exchangeRate = exchangeRate
) )
} }
} }
@ -327,6 +336,7 @@ private fun SendConfirmationMainContent(
submissionResults: ImmutableList<TransactionSubmitResult>, submissionResults: ImmutableList<TransactionSubmitResult>,
zecSend: ZecSend, zecSend: ZecSend,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
exchangeRate: FiatCurrencyResult
) { ) {
when (stage) { when (stage) {
SendConfirmationStage.Confirmation, SendConfirmationStage.Sending, is SendConfirmationStage.Failure -> { SendConfirmationStage.Confirmation, SendConfirmationStage.Sending, is SendConfirmationStage.Failure -> {
@ -335,7 +345,8 @@ private fun SendConfirmationMainContent(
onBack = onBack, onBack = onBack,
onConfirmation = onConfirmation, onConfirmation = onConfirmation,
isSending = stage == SendConfirmationStage.Sending, isSending = stage == SendConfirmationStage.Sending,
modifier = modifier modifier = modifier,
exchangeRate = exchangeRate
) )
if (stage is SendConfirmationStage.Failure) { if (stage is SendConfirmationStage.Failure) {
SendFailure( SendFailure(
@ -358,6 +369,7 @@ private fun SendConfirmationMainContent(
@Suppress("LongMethod") @Suppress("LongMethod")
private fun SendConfirmationContent( private fun SendConfirmationContent(
zecSend: ZecSend, zecSend: ZecSend,
exchangeRate: FiatCurrencyResult,
onConfirmation: () -> Unit, onConfirmation: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
isSending: Boolean, isSending: Boolean,
@ -380,6 +392,12 @@ private fun SendConfirmationContent(
isHideBalances = false isHideBalances = false
) )
StyledExchangeBalance(
zatoshi = zecSend.amount,
exchangeRate = exchangeRate,
isHideBalances = false
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault)) Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Small(stringResource(R.string.send_confirmation_address)) Small(stringResource(R.string.send_confirmation_address))

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="11dp"
android:height="9dp"
android:viewportWidth="11"
android:viewportHeight="9">
<group>
<clip-path
android:pathData="M0.5,0.601h10v8h-10z"/>
<path
android:pathData="M3.289,8.601L0.5,6.047V5.181H10.5V6.162H2.064L3.926,7.868L3.289,8.601ZM7.71,0.601L10.499,3.155V4.053L0.499,4.022V3.04H8.935L7.073,1.334L7.71,0.601Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</group>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="7dp"
android:height="13dp"
android:viewportWidth="7"
android:viewportHeight="13">
<path
android:pathData="M7.009,3.942V2.534H4.48V0.98H2.926V2.534H0.397V4.405H4.321L0.397,9.728V11.138H2.926V12.683H4.48V11.138H7.009V9.267H3.085L7.009,3.942Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="7dp"
android:height="12dp"
android:viewportWidth="7"
android:viewportHeight="12">
<path
android:pathData="M3.067,2.719V0.381H3.865V2.719H3.067ZM3.067,11.343V8.921H3.865V11.343H3.067ZM3.431,9.649C2.918,9.649 2.456,9.593 2.045,9.481C1.635,9.369 1.285,9.215 0.995,9.019C0.706,8.814 0.482,8.571 0.323,8.291C0.174,8.011 0.099,7.694 0.099,7.339C0.099,7.302 0.099,7.264 0.099,7.227C0.099,7.19 0.104,7.162 0.113,7.143H1.989C1.989,7.162 1.989,7.18 1.989,7.199C1.989,7.218 1.989,7.236 1.989,7.255C1.999,7.488 2.073,7.68 2.213,7.829C2.353,7.969 2.535,8.072 2.759,8.137C2.993,8.202 3.235,8.235 3.487,8.235C3.711,8.235 3.926,8.216 4.131,8.179C4.346,8.132 4.523,8.053 4.663,7.941C4.813,7.829 4.887,7.684 4.887,7.507C4.887,7.283 4.794,7.11 4.607,6.989C4.43,6.868 4.192,6.77 3.893,6.695C3.604,6.62 3.287,6.536 2.941,6.443C2.624,6.368 2.307,6.284 1.989,6.191C1.672,6.088 1.383,5.958 1.121,5.799C0.869,5.64 0.664,5.435 0.505,5.183C0.347,4.922 0.267,4.595 0.267,4.203C0.267,3.82 0.351,3.489 0.519,3.209C0.687,2.92 0.916,2.682 1.205,2.495C1.504,2.308 1.849,2.173 2.241,2.089C2.643,1.996 3.072,1.949 3.529,1.949C3.959,1.949 4.36,1.996 4.733,2.089C5.107,2.173 5.433,2.304 5.713,2.481C5.993,2.649 6.213,2.864 6.371,3.125C6.53,3.377 6.609,3.662 6.609,3.979C6.609,4.044 6.609,4.105 6.609,4.161C6.609,4.217 6.605,4.254 6.595,4.273H4.733V4.161C4.733,3.993 4.682,3.853 4.579,3.741C4.477,3.62 4.327,3.526 4.131,3.461C3.945,3.396 3.716,3.363 3.445,3.363C3.259,3.363 3.086,3.377 2.927,3.405C2.778,3.433 2.647,3.475 2.535,3.531C2.423,3.587 2.335,3.657 2.269,3.741C2.213,3.816 2.185,3.909 2.185,4.021C2.185,4.18 2.251,4.31 2.381,4.413C2.521,4.506 2.703,4.586 2.927,4.651C3.151,4.716 3.399,4.786 3.669,4.861C4.005,4.954 4.355,5.048 4.719,5.141C5.093,5.225 5.438,5.342 5.755,5.491C6.073,5.64 6.329,5.855 6.525,6.135C6.721,6.406 6.819,6.774 6.819,7.241C6.819,7.689 6.731,8.067 6.553,8.375C6.385,8.683 6.147,8.93 5.839,9.117C5.531,9.304 5.172,9.439 4.761,9.523C4.351,9.607 3.907,9.649 3.431,9.649Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -6,6 +6,7 @@
<string name="send_address_invalid">Invalid address</string> <string name="send_address_invalid">Invalid address</string>
<string name="send_amount_label">Amount:</string> <string name="send_amount_label">Amount:</string>
<string name="send_amount_hint"><xliff:g id="currency" example="ZEC">%1$s</xliff:g> Amount</string> <string name="send_amount_hint"><xliff:g id="currency" example="ZEC">%1$s</xliff:g> Amount</string>
<string name="send_usd_amount_hint"><xliff:g id="currency" example="USD">%1$s</xliff:g> Amount</string>
<string name="send_amount_insufficient_balance">Insufficient funds</string> <string name="send_amount_insufficient_balance">Insufficient funds</string>
<string name="send_amount_invalid">Invalid amount</string> <string name="send_amount_invalid">Invalid amount</string>
<string name="send_memo_label">Message</string> <string name="send_memo_label">Message</string>