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

784 lines
28 KiB
Kotlin

@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.send.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.spackle.Twig
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.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.canSpend
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
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.FormTextField
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
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.send.SendTag
import co.electriccoin.zcash.ui.screen.send.model.AmountState
import co.electriccoin.zcash.ui.screen.send.model.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import co.electriccoin.zcash.ui.screen.send.model.SendStage
import kotlinx.coroutines.launch
import java.util.Locale
@Composable
@Preview("SendForm")
private fun PreviewSendForm() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Send(
sendStage = SendStage.Form,
onCreateZecSend = {},
focusManager = LocalFocusManager.current,
onBack = {},
onSettings = {},
onQrScannerOpen = {},
goBalances = {},
hasCameraFeature = true,
recipientAddressState = RecipientAddressState("invalid_address", AddressType.Invalid()),
onRecipientAddressChange = {},
setAmountState = {},
amountState = AmountState.Valid(ZatoshiFixture.ZATOSHI_LONG.toString(), ZatoshiFixture.new()),
setMemoState = {},
memoState = MemoState.new("Test message"),
walletRestoringState = WalletRestoringState.NONE,
walletSnapshot = WalletSnapshotFixture.new(),
balanceState = BalanceStateFixture.new()
)
}
}
}
// TODO [#1260]: Cover Send screens UI with tests
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
@Suppress("LongParameterList")
@Composable
fun Send(
balanceState: BalanceState,
sendStage: SendStage,
onCreateZecSend: (ZecSend) -> Unit,
focusManager: FocusManager,
onBack: () -> Unit,
onSettings: () -> Unit,
onQrScannerOpen: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean,
recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit,
setAmountState: (AmountState) -> Unit,
amountState: AmountState,
setMemoState: (MemoState) -> Unit,
memoState: MemoState,
walletRestoringState: WalletRestoringState,
walletSnapshot: WalletSnapshot,
) {
Scaffold(topBar = {
SendTopAppBar(
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
onSettings = onSettings
)
}) { paddingValues ->
SendMainContent(
balanceState = balanceState,
walletSnapshot = walletSnapshot,
onBack = onBack,
focusManager = focusManager,
sendStage = sendStage,
onCreateZecSend = onCreateZecSend,
recipientAddressState = recipientAddressState,
onRecipientAddressChange = onRecipientAddressChange,
amountState = amountState,
setAmountState = setAmountState,
memoState = memoState,
setMemoState = setMemoState,
onQrScannerOpen = onQrScannerOpen,
goBalances = goBalances,
hasCameraFeature = hasCameraFeature,
modifier =
Modifier
.padding(
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingHuge,
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular
)
)
}
}
@Composable
private fun SendTopAppBar(
onSettings: () -> Unit,
showRestoring: Boolean
) {
SmallTopAppBar(
restoringLabel =
if (showRestoring) {
stringResource(id = R.string.restoring_wallet_label)
} else {
null
},
titleText = stringResource(id = R.string.send_stage_send_title),
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 SendMainContent(
balanceState: BalanceState,
walletSnapshot: WalletSnapshot,
focusManager: FocusManager,
onBack: () -> Unit,
goBalances: () -> Unit,
onCreateZecSend: (ZecSend) -> Unit,
sendStage: SendStage,
onQrScannerOpen: () -> Unit,
recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit,
hasCameraFeature: Boolean,
amountState: AmountState,
setAmountState: (AmountState) -> Unit,
memoState: MemoState,
setMemoState: (MemoState) -> Unit,
modifier: Modifier = Modifier,
) {
// For now, we merge [SendStage.Form] and [SendStage.Proposing] into one stage. We could eventually display a
// loader if calling the Proposal API takes longer than expected
SendForm(
balanceState = balanceState,
walletSnapshot = walletSnapshot,
recipientAddressState = recipientAddressState,
onRecipientAddressChange = onRecipientAddressChange,
amountState = amountState,
setAmountState = setAmountState,
memoState = memoState,
setMemoState = setMemoState,
onCreateZecSend = onCreateZecSend,
focusManager = focusManager,
onQrScannerOpen = onQrScannerOpen,
goBalances = goBalances,
hasCameraFeature = hasCameraFeature,
modifier = modifier
)
if (sendStage is SendStage.SendFailure) {
SendFailure(
reason = sendStage.error,
onDone = onBack
)
}
}
const val DEFAULT_LESS_THAN_FEE = 100_000L
// TODO [#217]: Need to handle changing of Locale after user input, but before submitting the button.
// TODO [#217]: https://github.com/Electric-Coin-Company/zashi-android/issues/217
// TODO [#1257]: Send.Form TextFields not persisted on a configuration change when the underlying ViewPager is on the
// Balances page
// TODO [#1257]: https://github.com/Electric-Coin-Company/zashi-android/issues/1257
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun SendForm(
balanceState: BalanceState,
walletSnapshot: WalletSnapshot,
focusManager: FocusManager,
recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit,
amountState: AmountState,
setAmountState: (AmountState) -> Unit,
memoState: MemoState,
setMemoState: (MemoState) -> Unit,
onCreateZecSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean,
modifier: Modifier = Modifier,
) {
// TODO [#1171]: Remove default MonetarySeparators locale
// TODO [#1171]: https://github.com/Electric-Coin-Company/zashi-android/issues/1171
val monetarySeparators = MonetarySeparators.current(Locale.US)
val scrollState = rememberScrollState()
val (scrollToFeePixels, setScrollToFeePixels) = rememberSaveable { mutableIntStateOf(0) }
Column(
modifier =
Modifier
.fillMaxHeight()
.verticalScroll(scrollState)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BalanceWidget(
balanceState = balanceState,
isReferenceToBalances = true,
onReferenceClick = goBalances
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
// TODO [#1256]: Consider Send.Form TextFields scrolling
// TODO [#1256]: https://github.com/Electric-Coin-Company/zashi-android/issues/1256
SendFormAddressTextField(
focusManager = focusManager,
hasCameraFeature = hasCameraFeature,
onQrScannerOpen = onQrScannerOpen,
recipientAddressState = recipientAddressState,
setRecipientAddress = onRecipientAddressChange
)
Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault))
SendFormAmountTextField(
amountSate = amountState,
focusManager = focusManager,
imeAction =
if (recipientAddressState.type == AddressType.Transparent) {
ImeAction.Done
} else {
ImeAction.Next
},
isTransparentRecipient = recipientAddressState.type?.let { it == AddressType.Transparent } ?: false,
monetarySeparators = monetarySeparators,
setAmountState = setAmountState,
walletSnapshot = walletSnapshot,
)
Spacer(Modifier.size(ZcashTheme.dimens.spacingDefault))
SendFormMemoTextField(
memoState = memoState,
setMemoState = setMemoState,
focusManager = focusManager,
isMemoFieldAvailable = (
recipientAddressState.address.isEmpty() ||
recipientAddressState.type is AddressType.Invalid ||
(
recipientAddressState.type is AddressType.Valid &&
recipientAddressState.type !is AddressType.Transparent
)
),
scrollState = scrollState,
scrollTo = scrollToFeePixels
)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
SendButton(
amountState = amountState,
memoState = memoState,
monetarySeparators = monetarySeparators,
onCreateZecSend = onCreateZecSend,
recipientAddressState = recipientAddressState,
walletSnapshot = walletSnapshot,
setScrollToFeePixels = setScrollToFeePixels
)
}
}
@Composable
@Suppress("LongParameterList")
fun SendButton(
amountState: AmountState,
memoState: MemoState,
monetarySeparators: MonetarySeparators,
onCreateZecSend: (ZecSend) -> Unit,
recipientAddressState: RecipientAddressState,
setScrollToFeePixels: (Int) -> Unit,
walletSnapshot: WalletSnapshot,
) {
val context = LocalContext.current
// Common conditions continuously checked for validity
val sendButtonEnabled =
recipientAddressState.type !is AddressType.Invalid &&
recipientAddressState.address.isNotEmpty() &&
amountState is AmountState.Valid &&
amountState.value.isNotBlank() &&
walletSnapshot.canSpend(amountState.zatoshi) &&
// A valid memo is necessary only for non-transparent recipient
(recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct)
Column(
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular),
horizontalAlignment = Alignment.CenterHorizontally
) {
PrimaryButton(
onClick = {
// SDK side validations
val zecSendValidation =
ZecSendExt.new(
context = context,
destinationString = recipientAddressState.address,
zecString = amountState.value,
// Take memo for a valid non-transparent receiver only
memoString =
if (recipientAddressState.type == AddressType.Transparent) {
""
} else {
memoState.text
},
monetarySeparators = monetarySeparators
)
when (zecSendValidation) {
is ZecSendExt.ZecSendValidation.Valid -> onCreateZecSend(zecSendValidation.zecSend)
is ZecSendExt.ZecSendValidation.Invalid -> {
// We do not expect this validation to fail, so logging is enough here
// An error popup could be reasonable here as well
Twig.warn { "Send failed with: ${zecSendValidation.validationErrors}" }
}
}
},
text = stringResource(id = R.string.send_create),
enabled = sendButtonEnabled,
modifier =
Modifier
.testTag(SendTag.SEND_FORM_BUTTON)
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BodySmall(
text =
stringResource(
id = R.string.send_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,
modifier =
Modifier.onGloballyPositioned {
setScrollToFeePixels(it.positionInRoot().y.toInt())
}
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
fun SendFormAddressTextField(
focusManager: FocusManager,
hasCameraFeature: Boolean,
onQrScannerOpen: () -> Unit,
recipientAddressState: RecipientAddressState,
setRecipientAddress: (String) -> Unit,
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Column(
modifier =
Modifier
// Animate error show/hide
.animateContentSize()
// Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester)
) {
Small(text = stringResource(id = R.string.send_address_label))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
val recipientAddressValue = recipientAddressState.address
val recipientAddressError =
if (
recipientAddressValue.isNotEmpty() &&
recipientAddressState.type is AddressType.Invalid
) {
stringResource(id = R.string.send_address_invalid)
} else {
null
}
FormTextField(
value = recipientAddressValue,
onValueChange = {
setRecipientAddress(it)
},
modifier =
Modifier
.fillMaxWidth(),
error = recipientAddressError,
placeholder = {
Text(
text = stringResource(id = R.string.send_address_hint),
style = ZcashTheme.extendedTypography.textFieldHint,
color = ZcashTheme.colors.textFieldHint
)
},
trailingIcon =
if (hasCameraFeature) {
{
IconButton(
onClick = onQrScannerOpen,
content = {
Icon(
painter = painterResource(id = R.drawable.qr_code_icon),
contentDescription = stringResource(R.string.send_scan_content_description)
)
}
)
}
} else {
null
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions =
KeyboardActions(
onNext = {
focusManager.moveFocus(FocusDirection.Down)
}
),
bringIntoViewRequester = bringIntoViewRequester,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList", "LongMethod")
@Composable
fun SendFormAmountTextField(
amountSate: AmountState,
focusManager: FocusManager,
imeAction: ImeAction,
isTransparentRecipient: Boolean,
monetarySeparators: MonetarySeparators,
setAmountState: (AmountState) -> Unit,
walletSnapshot: WalletSnapshot,
) {
val context = LocalContext.current
val zcashCurrency = ZcashCurrency.getLocalizedName(context)
val amountError =
when (amountSate) {
is AmountState.Invalid -> {
if (amountSate.value.isEmpty()) {
null
} else {
stringResource(id = R.string.send_amount_invalid)
}
}
is AmountState.Valid -> {
if (walletSnapshot.spendableBalance() < amountSate.zatoshi) {
stringResource(id = R.string.send_amount_insufficient_balance)
} else {
null
}
}
}
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Column(
modifier =
Modifier
// Animate error show/hide
.animateContentSize()
// Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester)
) {
Small(text = stringResource(id = R.string.send_amount_label))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
FormTextField(
value = amountSate.value,
onValueChange = { newValue ->
setAmountState(
AmountState.new(
context = context,
value = newValue,
monetarySeparators = monetarySeparators,
isTransparentRecipient = isTransparentRecipient
)
)
},
modifier = Modifier.fillMaxWidth(),
error = amountError,
placeholder = {
Text(
text =
stringResource(
id = R.string.send_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,
)
}
}
// TODO [#1259]: Send.Form screen Memo field stroke bubble style
// TODO [#1259]: https://github.com/Electric-Coin-Company/zashi-android/issues/1259
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "LongParameterList")
@Composable
fun SendFormMemoTextField(
focusManager: FocusManager,
isMemoFieldAvailable: Boolean,
memoState: MemoState,
setMemoState: (MemoState) -> Unit,
scrollState: ScrollState,
scrollTo: Int
) {
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Column(
modifier =
Modifier
// Animate error show/hide
.animateContentSize()
// Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.send_paper_plane),
contentDescription = null,
tint =
if (isMemoFieldAvailable) {
ZcashTheme.colors.textCommon
} else {
ZcashTheme.colors.textDisabled
}
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Small(
text = stringResource(id = R.string.send_memo_label),
color =
if (isMemoFieldAvailable) {
ZcashTheme.colors.textCommon
} else {
ZcashTheme.colors.textDisabled
}
)
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
FormTextField(
enabled = isMemoFieldAvailable,
value =
if (isMemoFieldAvailable) {
memoState.text
} else {
""
},
onValueChange = {
setMemoState(MemoState.new(it))
},
bringIntoViewRequester = bringIntoViewRequester,
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions =
KeyboardActions(
onDone = {
focusManager.clearFocus(true)
// Scroll down to make sure the Send button is visible on small screens
if (scrollTo > 0) {
scope.launch {
scrollState.animateScrollTo(scrollTo)
}
}
}
),
placeholder = {
Text(
text = stringResource(id = R.string.send_memo_hint),
style = ZcashTheme.extendedTypography.textFieldHint,
color = ZcashTheme.colors.textFieldHint
)
},
modifier = Modifier.fillMaxWidth(),
minHeight = ZcashTheme.dimens.textFieldMemoPanelDefaultHeight,
)
if (isMemoFieldAvailable) {
Body(
text =
stringResource(
id = R.string.send_memo_bytes_counter,
Memo.MAX_MEMO_LENGTH_BYTES - memoState.byteSize,
Memo.MAX_MEMO_LENGTH_BYTES
),
textFontWeight = FontWeight.Bold,
color =
if (memoState is MemoState.Correct) {
ZcashTheme.colors.textFieldHint
} else {
ZcashTheme.colors.textFieldError
},
textAlign = TextAlign.End,
modifier =
Modifier
.fillMaxWidth()
.padding(top = ZcashTheme.dimens.spacingTiny)
)
}
}
}
@Composable
@Preview("SendFailure")
private fun PreviewSendFailure() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
SendFailure(
onDone = {},
reason = "Insufficient balance"
)
}
}
}
@Composable
private fun SendFailure(
onDone: () -> Unit,
reason: String,
modifier: Modifier = Modifier
) {
// TODO [#1276]: Once we ensure that the 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.send_dialog_error_title),
text = {
Column(
Modifier.verticalScroll(rememberScrollState())
) {
Text(text = stringResource(id = R.string.send_dialog_error_text))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Text(
text = reason,
fontStyle = FontStyle.Italic
)
}
},
confirmButtonText = stringResource(id = R.string.send_dialog_error_btn),
onConfirmButtonClick = onDone,
modifier = modifier
)
}