secant-android-wallet/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/balances/view/BalancesView.kt

638 lines
23 KiB
Kotlin

@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.balances.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.compose.BalanceWidget
import co.electriccoin.zcash.ui.common.compose.SynchronizationStatus
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.changePendingBalance
import co.electriccoin.zcash.ui.common.model.hasChangePending
import co.electriccoin.zcash.ui.common.model.hasValuePending
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.model.valuePendingBalance
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BodySmall
import co.electriccoin.zcash.ui.design.component.BodyWithFiatCurrencySymbol
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.CircularSmallProgressIndicator
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Reference
import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.balances.BalancesTag
import co.electriccoin.zcash.ui.screen.balances.model.ShieldState
import co.electriccoin.zcash.ui.screen.balances.model.WalletDisplayValues
@Preview("Balances")
@Composable
private fun ComposableBalancesPreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Balances(
onSettings = {},
isDetailedStatus = false,
isFiatConversionEnabled = false,
isUpdateAvailable = false,
isShowingErrorDialog = false,
setShowErrorDialog = {},
onShielding = {},
shieldState = ShieldState.Available,
walletSnapshot = WalletSnapshotFixture.new(),
walletRestoringState = WalletRestoringState.NONE,
balanceState = BalanceStateFixture.new(),
)
}
}
}
@Preview("BalancesShieldFailure")
@Composable
private fun ComposableBalancesShieldFailurePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Balances(
onSettings = {},
isDetailedStatus = false,
isFiatConversionEnabled = false,
isUpdateAvailable = false,
isShowingErrorDialog = true,
setShowErrorDialog = {},
onShielding = {},
shieldState = ShieldState.Available,
walletSnapshot = WalletSnapshotFixture.new(),
walletRestoringState = WalletRestoringState.NONE,
balanceState = BalanceStateFixture.new(),
)
}
}
}
@Suppress("LongParameterList")
@Composable
fun Balances(
onSettings: () -> Unit,
isDetailedStatus: Boolean,
isFiatConversionEnabled: Boolean,
isUpdateAvailable: Boolean,
isShowingErrorDialog: Boolean,
setShowErrorDialog: (Boolean) -> Unit,
onShielding: () -> Unit,
shieldState: ShieldState,
walletSnapshot: WalletSnapshot?,
walletRestoringState: WalletRestoringState,
balanceState: BalanceState,
) {
Scaffold(topBar = {
BalancesTopAppBar(
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
onSettings = onSettings
)
}) { paddingValues ->
if (null == walletSnapshot) {
CircularScreenProgressIndicator()
} else {
BalancesMainContent(
balanceState = balanceState,
isDetailedStatus = isDetailedStatus,
isFiatConversionEnabled = isFiatConversionEnabled,
isUpdateAvailable = isUpdateAvailable,
onShielding = onShielding,
walletSnapshot = walletSnapshot,
shieldState = shieldState,
modifier =
Modifier.padding(
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingHuge,
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular
),
walletRestoringState = walletRestoringState
)
// Show shielding error popup
if (isShowingErrorDialog && shieldState is ShieldState.Failed) {
ShieldingErrorDialog(
reason = shieldState.error,
onDone = { setShowErrorDialog(false) }
)
}
}
}
}
@Composable
fun ShieldingErrorDialog(
reason: String,
onDone: () -> Unit
) {
// TODO [#1276]: Once we ensure that reason contains a localized message, we can leverage it for the UI prompt
// TODO [#1276]: Consider adding support for a specific exception in AppAlertDialog
// TODO [#1276]: https://github.com/Electric-Coin-Company/zashi-android/issues/1276
AppAlertDialog(
title = stringResource(id = R.string.balances_shielding_dialog_error_title),
text = {
Column(
Modifier.verticalScroll(rememberScrollState())
) {
Text(text = stringResource(id = R.string.balances_shielding_dialog_error_text))
if (reason.isNotEmpty()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Text(
text = reason,
fontStyle = FontStyle.Italic
)
}
}
},
confirmButtonText = stringResource(id = R.string.balances_shielding_dialog_error_btn),
onConfirmButtonClick = onDone
)
}
@Composable
private fun BalancesTopAppBar(
onSettings: () -> Unit,
showRestoring: Boolean
) {
SmallTopAppBar(
restoringLabel =
if (showRestoring) {
stringResource(id = R.string.restoring_wallet_label)
} else {
null
},
titleText = stringResource(id = R.string.balances_title),
showTitleLogo = false,
hamburgerMenuActions = {
IconButton(
onClick = onSettings,
modifier = Modifier.testTag(CommonTag.SETTINGS_TOP_BAR_BUTTON)
) {
Icon(
painter = painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.hamburger_menu_icon),
contentDescription = stringResource(id = R.string.settings_menu_content_description)
)
}
},
)
}
@Suppress("LongParameterList")
@Composable
private fun BalancesMainContent(
balanceState: BalanceState,
isDetailedStatus: Boolean,
isFiatConversionEnabled: Boolean,
isUpdateAvailable: Boolean,
onShielding: () -> Unit,
walletSnapshot: WalletSnapshot,
shieldState: ShieldState,
walletRestoringState: WalletRestoringState,
modifier: Modifier = Modifier,
) {
Column(
modifier =
Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BalanceWidget(
balanceState = balanceState,
isReferenceToBalances = false,
onReferenceClick = {}
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
HorizontalDivider(
color = ZcashTheme.colors.darkDividerColor,
thickness = ZcashTheme.dimens.divider
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BalancesOverview(
walletSnapshot = walletSnapshot,
isFiatConversionEnabled = isFiatConversionEnabled,
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
TransparentBalancePanel(
onShielding = onShielding,
shieldState = shieldState,
walletSnapshot = walletSnapshot,
)
Spacer(modifier = Modifier.weight(1f, true))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
if (walletRestoringState == WalletRestoringState.RESTORING) {
Small(
text = stringResource(id = R.string.balances_status_restoring_text),
textFontWeight = FontWeight.Medium,
color = ZcashTheme.colors.textFieldWarning,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = ZcashTheme.dimens.spacingDefault)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
SynchronizationStatus(
walletSnapshot = walletSnapshot,
isUpdateAvailable = isUpdateAvailable,
isDetailedStatus = isDetailedStatus,
testTag = BalancesTag.STATUS
)
}
}
const val DEFAULT_LESS_THAN_FEE = 100_000L
@Composable
fun TransparentBalancePanel(
onShielding: () -> Unit,
shieldState: ShieldState,
walletSnapshot: WalletSnapshot,
) {
var showHelpPanel by rememberSaveable { mutableStateOf(false) }
Box(
modifier =
Modifier
.background(color = ZcashTheme.colors.panelBackgroundColor)
.wrapContentSize()
.animateContentSize()
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
TransparentBalanceRow(
isProgressbarVisible = shieldState == ShieldState.Running,
onHelpClick = { showHelpPanel = !showHelpPanel },
walletSnapshot = walletSnapshot
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
PrimaryButton(
onClick = onShielding,
text = stringResource(R.string.balances_transparent_balance_shield),
textStyle = ZcashTheme.extendedTypography.buttonTextSmall,
enabled = shieldState == ShieldState.Available,
minHeight = ZcashTheme.dimens.buttonHeightSmall,
modifier = Modifier.fillMaxWidth(),
outerPaddingValues =
PaddingValues(
horizontal = 54.dp,
vertical = ZcashTheme.dimens.spacingSmall
)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BodySmall(
text =
stringResource(
id = R.string.balances_transparent_balance_fee,
// TODO [#1047]: Representing Zatoshi amount
// TODO [#1047]: https://github.com/Electric-Coin-Company/zashi-android/issues/1047
Zatoshi(DEFAULT_LESS_THAN_FEE).convertZatoshiToZecString(maxDecimals = 3)
),
textFontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
}
if (showHelpPanel) {
TransparentBalanceHelpPanel(
onHideHelpPanel = { showHelpPanel = !showHelpPanel }
)
}
}
}
@Composable
fun TransparentBalanceRow(
isProgressbarVisible: Boolean,
onHelpClick: () -> Unit,
walletSnapshot: WalletSnapshot,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(
start = ZcashTheme.dimens.spacingDefault,
end = ZcashTheme.dimens.spacingSmall
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// To keep both elements together in relatively sized row
Row(modifier = Modifier.fillMaxWidth(TEXT_PART_WIDTH_RATIO)) {
// Apply common click listener
Row(
modifier =
Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.smallRippleEffectCorner))
.clickable { onHelpClick() }
.padding(end = ZcashTheme.dimens.spacingXtiny)
) {
BodySmall(text = stringResource(id = R.string.balances_transparent_balance).uppercase())
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_help_question_mark),
contentDescription = stringResource(id = R.string.balances_transparent_help_content_description),
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.spacingXtiny)
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
StyledBalance(
balanceString = walletSnapshot.transparentBalance.toZecString(),
textStyles =
Pair(
ZcashTheme.extendedTypography.balanceSingleStyles.first,
ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textPending
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
Box(Modifier.width(ZcashTheme.dimens.circularSmallProgressWidth)) {
if (isProgressbarVisible) {
CircularSmallProgressIndicator()
}
}
}
}
}
@Composable
fun TransparentBalanceHelpPanel(onHideHelpPanel: () -> Unit) {
Column(
modifier =
Modifier
.padding(all = ZcashTheme.dimens.spacingDefault)
.background(color = Color.White)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
val appName = stringResource(id = R.string.app_name)
val currencyName = ZcashCurrency.getLocalizedName(LocalContext.current)
BodySmall(
text =
stringResource(
id = R.string.balances_transparent_balance_help,
appName,
currencyName
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.spacingDefault)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Reference(
text = stringResource(id = R.string.balances_transparent_balance_help_close).uppercase(),
onClick = onHideHelpPanel,
textStyle = ZcashTheme.extendedTypography.referenceSmall
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}
@Composable
fun BalancesOverview(
walletSnapshot: WalletSnapshot,
isFiatConversionEnabled: Boolean,
) {
Column {
SpendableBalanceRow(walletSnapshot)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
ChangePendingRow(walletSnapshot)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
// aka value pending
PendingTransactionsRow(walletSnapshot)
if (isFiatConversionEnabled) {
val walletDisplayValues =
WalletDisplayValues.getNextValues(
context = LocalContext.current,
walletSnapshot = walletSnapshot,
isUpdateAvailable = false,
isDetailedStatus = false
)
Column(Modifier.testTag(BalancesTag.FIAT_CONVERSION)) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
when (walletDisplayValues.fiatCurrencyAmountState) {
is FiatCurrencyConversionRateState.Current -> {
BodyWithFiatCurrencySymbol(
amount = walletDisplayValues.fiatCurrencyAmountText
)
}
is FiatCurrencyConversionRateState.Stale -> {
// Note: we should show information about staleness too
BodyWithFiatCurrencySymbol(
amount = walletDisplayValues.fiatCurrencyAmountText
)
}
is FiatCurrencyConversionRateState.Unavailable -> {
Body(text = walletDisplayValues.fiatCurrencyAmountText)
}
}
}
}
}
}
const val TEXT_PART_WIDTH_RATIO = 0.6f
@Composable
fun SpendableBalanceRow(walletSnapshot: WalletSnapshot) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
BodySmall(
text = stringResource(id = R.string.balances_shielded_spendable).uppercase(),
modifier = Modifier.fillMaxWidth(TEXT_PART_WIDTH_RATIO)
)
Row(verticalAlignment = Alignment.CenterVertically) {
StyledBalance(
balanceString = walletSnapshot.spendableBalance().toZecString(),
textStyles =
Pair(
ZcashTheme.extendedTypography.balanceSingleStyles.first,
ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textCommon
)
Spacer(modifier = Modifier.width(12.dp))
Icon(
imageVector = ImageVector.vectorResource(R.drawable.balance_shield),
contentDescription = null,
// The same size as the following progress bars
modifier = Modifier.width(ZcashTheme.dimens.circularSmallProgressWidth)
)
}
}
}
@Composable
fun ChangePendingRow(walletSnapshot: WalletSnapshot) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
BodySmall(
text = stringResource(id = R.string.balances_change_pending).uppercase(),
modifier = Modifier.fillMaxWidth(TEXT_PART_WIDTH_RATIO)
)
Row(verticalAlignment = Alignment.CenterVertically) {
StyledBalance(
balanceString = walletSnapshot.changePendingBalance().toZecString(),
textStyles =
Pair(
ZcashTheme.extendedTypography.balanceSingleStyles.first,
ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textPending
)
Spacer(modifier = Modifier.width(12.dp))
Box(Modifier.width(ZcashTheme.dimens.circularSmallProgressWidth)) {
if (walletSnapshot.hasChangePending()) {
CircularSmallProgressIndicator()
}
}
}
}
}
@Composable
fun PendingTransactionsRow(walletSnapshot: WalletSnapshot) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
BodySmall(
text = stringResource(id = R.string.balances_pending_transactions).uppercase(),
modifier = Modifier.fillMaxWidth(TEXT_PART_WIDTH_RATIO)
)
Row(verticalAlignment = Alignment.CenterVertically) {
StyledBalance(
balanceString = walletSnapshot.valuePendingBalance().toZecString(),
textStyles =
Pair(
ZcashTheme.extendedTypography.balanceSingleStyles.first,
ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textPending
)
Spacer(modifier = Modifier.width(12.dp))
Box(Modifier.width(ZcashTheme.dimens.circularSmallProgressWidth)) {
if (walletSnapshot.hasValuePending()) {
CircularSmallProgressIndicator()
}
}
}
}
}