Balance actions bussiness logic

This commit is contained in:
Milan Cerovsky 2025-04-15 16:51:19 +02:00
parent 2b9635502e
commit 64af1a3d50
30 changed files with 397 additions and 181 deletions

View File

@ -34,11 +34,6 @@ fun Zatoshi.toZecStringAbbreviated(suffix: String): ZecAmountPair {
}
}
private const val DEFAULT_LESS_THAN_FEE = 100_000L
val DEFAULT_FEE: String
get() = Zatoshi(DEFAULT_LESS_THAN_FEE).toZecStringFull()
data class ZecAmountPair(
val main: String,
val suffix: String

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
@ -66,6 +67,7 @@ fun StyledBalance(
balanceParts: ZecAmountTriple,
modifier: Modifier = Modifier,
isHideBalances: Boolean = false,
showDust: Boolean = true,
hiddenBalancePlaceholder: String = stringResource(id = R.string.hide_balance_placeholder),
textColor: Color = Color.Unspecified,
textStyle: BalanceTextStyle = StyledBalanceDefaults.textStyles(),
@ -88,10 +90,12 @@ fun StyledBalance(
) {
append(balanceSplit.first)
}
withStyle(
style = textStyle.leastSignificantPart.toSpanStyle()
) {
append(balanceSplit.second)
if (showDust) {
withStyle(
style = textStyle.leastSignificantPart.toSpanStyle()
) {
append(balanceSplit.second)
}
}
}
}
@ -101,12 +105,14 @@ fun StyledBalance(
.basicMarquee()
.then(modifier)
Text(
text = content,
color = textColor,
maxLines = 1,
modifier = resultModifier
)
SelectionContainer {
Text(
text = content,
color = textColor,
maxLines = 1,
modifier = resultModifier
)
}
}
private const val CUT_POSITION_OFFSET = 4

View File

@ -1,6 +1,7 @@
package co.electriccoin.zcash.ui.design.component
import androidx.annotation.DrawableRes
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@ -13,10 +14,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -36,6 +44,9 @@ fun ZashiBigIconButton(
state: BigIconButtonState,
modifier: Modifier = Modifier,
) {
var isPressed by remember { mutableStateOf(false) }
val shadowElevation by animateDpAsState(if (isPressed) 0.dp else (2.dp orDark 4.dp))
val darkBgGradient =
Brush.verticalGradient(
0f to ZashiColors.Surfaces.strokeSecondary,
@ -54,14 +65,29 @@ fun ZashiBigIconButton(
Modifier.background(darkBgGradient)
Surface(
modifier = modifier,
modifier = modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
event.changes.forEach { change ->
if (change.changedToDown()) {
isPressed = true
}
if (change.changedToUp()) {
isPressed = false
}
}
}
}
},
onClick = state.onClick,
color = ZashiColors.Surfaces.bgPrimary,
shape = RoundedCornerShape(22.dp),
border =
BorderStroke(.5.dp, ZashiColors.Utility.Gray.utilityGray100) orDark
BorderStroke(.5.dp, darkBorderGradient),
shadowElevation = 2.dp orDark 4.dp
shadowElevation = shadowElevation
) {
Column(
modifier = backgroundModifier.padding(16.dp),

View File

@ -57,8 +57,15 @@ sealed interface StringResource {
val address: String,
val abbreviated: Boolean
) : StringResource
operator fun plus(other: StringResource): StringResource = CompositeStringResource(listOf(this, other))
operator fun plus(other: String): StringResource = CompositeStringResource(listOf(this, stringRes(other)))
}
@Immutable
private data class CompositeStringResource(val resources: List<StringResource>): StringResource
@Stable
fun stringRes(
@StringRes resource: Int,
@ -109,18 +116,14 @@ fun StringResource.getValue(
convertYearMonth: (YearMonth) -> String = StringResourceDefaults::convertYearMonth,
convertAddress: (StringResource.ByAddress) -> String = StringResourceDefaults::convertAddress,
convertTransactionId: (StringResource.ByTransactionId) -> String = StringResourceDefaults::convertTransactionId
) = when (this) {
is StringResource.ByResource -> {
val context = LocalContext.current
context.getString(resource, *args.normalize(context).toTypedArray())
}
is StringResource.ByString -> value
is StringResource.ByZatoshi -> convertZatoshi(zatoshi)
is StringResource.ByDateTime -> convertDateTime(this)
is StringResource.ByYearMonth -> convertYearMonth(yearMonth)
is StringResource.ByAddress -> convertAddress(this)
is StringResource.ByTransactionId -> convertTransactionId(this)
}
): String = getString(
context = LocalContext.current,
convertZatoshi = convertZatoshi,
convertDateTime = convertDateTime,
convertYearMonth = convertYearMonth,
convertAddress = convertAddress,
convertTransactionId = convertTransactionId
)
@Suppress("SpreadOperator")
fun StringResource.getString(
@ -130,7 +133,7 @@ fun StringResource.getString(
convertYearMonth: (YearMonth) -> String = StringResourceDefaults::convertYearMonth,
convertAddress: (StringResource.ByAddress) -> String = StringResourceDefaults::convertAddress,
convertTransactionId: (StringResource.ByTransactionId) -> String = StringResourceDefaults::convertTransactionId
) = when (this) {
): String = when (this) {
is StringResource.ByResource -> context.getString(resource, *args.normalize(context).toTypedArray())
is StringResource.ByString -> value
is StringResource.ByZatoshi -> convertZatoshi(zatoshi)
@ -138,6 +141,16 @@ fun StringResource.getString(
is StringResource.ByYearMonth -> convertYearMonth(yearMonth)
is StringResource.ByAddress -> convertAddress(this)
is StringResource.ByTransactionId -> convertTransactionId(this)
is CompositeStringResource -> this.resources.joinToString(separator = "") {
it.getString(
context = context,
convertZatoshi = convertZatoshi,
convertDateTime = convertDateTime,
convertYearMonth = convertYearMonth,
convertAddress = convertAddress,
convertTransactionId = convertTransactionId,
)
}
}
private fun List<Any>.normalize(context: Context): List<Any> =

View File

@ -104,7 +104,7 @@ class SendViewTestSetup(
// TODO [#1260]: Cover Send.Form screen UI with tests
// TODO [#1260]: https://github.com/Electric-Coin-Company/zashi-android/issues/1260
Send(
balanceState = BalanceStateFixture.new(),
balanceWidgetState = BalanceStateFixture.new(),
sendStage = sendStage,
onCreateZecSend = setZecSend,
onBack = onBackAction,

View File

@ -8,7 +8,7 @@ import co.electriccoin.zcash.ui.screen.accountlist.viewmodel.AccountListViewMode
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.AddressBookViewModel
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.SelectRecipientViewModel
import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsViewModel
import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetViewModel
import co.electriccoin.zcash.ui.screen.balances.action.BalanceActionViewModel
import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
@ -143,7 +143,7 @@ val viewModelModule =
)
}
viewModelOf(::TaxExportViewModel)
viewModelOf(::BalanceViewModel)
viewModelOf(::BalanceWidgetViewModel)
viewModelOf(::HomeViewModel)
viewModelOf(::RestoreBDHeightViewModel)
viewModelOf(::RestoreBDDateViewModel)

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import java.time.Instant
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
interface ShieldFundsDataSource {
@ -87,7 +88,7 @@ sealed interface ShieldFundsAvailability {
}
enum class ShieldFundsLockoutDuration(val duration: Duration, @StringRes val res: Int) {
TWO_DAYS(10.seconds, R.string.general_remind_me_in_two_days),
TWO_WEEKS(20.seconds, R.string.general_remind_me_in_two_weeks),
ONE_MONTH(30.seconds, R.string.general_remind_me_in_two_months)
TWO_DAYS(2.days, R.string.general_remind_me_in_two_days),
TWO_WEEKS(2.days, R.string.general_remind_me_in_two_weeks),
ONE_MONTH(30.days, R.string.general_remind_me_in_two_months)
}

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import java.time.Instant
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
interface WalletBackupDataSource {
@ -96,7 +97,7 @@ sealed interface WalletBackupAvailability {
}
enum class WalletBackupLockoutDuration(val duration: Duration, @StringRes val res: Int) {
TWO_DAYS(10.seconds, R.string.general_remind_me_in_two_days),
TWO_WEEKS(20.seconds, R.string.general_remind_me_in_two_weeks),
ONE_MONTH(30.seconds, R.string.general_remind_me_in_two_months),
TWO_DAYS(2.days, R.string.general_remind_me_in_two_days),
TWO_WEEKS(14.days, R.string.general_remind_me_in_two_weeks),
ONE_MONTH(30.days, R.string.general_remind_me_in_two_months),
}

View File

@ -29,11 +29,25 @@ sealed interface WalletAccount : Comparable<WalletAccount> {
val totalShieldedBalance: Zatoshi
val spendableBalance: Zatoshi
val changePendingBalance: Zatoshi
val hasChangePending: Boolean
val valuePendingBalance: Zatoshi
val pendingBalance: Zatoshi
get() = changePendingBalance + valuePendingBalance
val hasChangePending: Boolean
val hasValuePending: Boolean
val isPending: Boolean
get() = pendingBalance > Zatoshi(0)
fun canSpend(amount: Zatoshi): Boolean = spendableBalance >= amount
fun isProcessingZeroAvailableBalance(): Boolean {
if (totalShieldedBalance == Zatoshi(0) && transparent.balance > Zatoshi(0)) {
return false
}
return totalBalance != totalShieldedBalance && totalShieldedBalance == Zatoshi(0)
}
}
data class ZashiAccount(
@ -55,10 +69,10 @@ data class ZashiAccount(
get() = unified.balance.available + sapling.balance.available
override val changePendingBalance: Zatoshi
get() = unified.balance.changePending + sapling.balance.changePending
override val hasChangePending: Boolean
get() = changePendingBalance.value > 0L
override val valuePendingBalance: Zatoshi
get() = unified.balance.valuePending + sapling.balance.valuePending
override val hasChangePending: Boolean
get() = changePendingBalance.value > 0L
override val hasValuePending: Boolean
get() = valuePendingBalance.value > 0L
@ -88,10 +102,10 @@ data class KeystoneAccount(
get() = unified.balance.available
override val changePendingBalance: Zatoshi
get() = unified.balance.changePending
override val hasChangePending: Boolean
get() = changePendingBalance.value > 0L
override val valuePendingBalance: Zatoshi
get() = unified.balance.valuePending
override val hasChangePending: Boolean
get() = changePendingBalance.value > 0L
override val hasValuePending: Boolean
get() = valuePendingBalance.value > 0L
@ -110,7 +124,10 @@ data class UnifiedInfo(
data class TransparentInfo(
val address: WalletAddress.Transparent,
val balance: Zatoshi
)
) {
val isShieldingAvailable: Boolean
get() = balance > Zatoshi(100000L)
}
data class SaplingInfo(
val address: WalletAddress.Sapling,

View File

@ -29,7 +29,7 @@ class ShieldFundsRepositoryImpl(
account == null ->
flowOf(ShieldFundsData.Unavailable)
account.transparent.balance >= Zatoshi(DEFAULT_SHIELDING_THRESHOLD) ->
account.transparent.isShieldingAvailable ->
shieldFundsDataSource.observe(account.sdkAccount.accountUuid).map {
when (it) {
is ShieldFundsAvailability.Available -> ShieldFundsData.Available(
@ -60,6 +60,3 @@ sealed interface ShieldFundsData {
data object Unavailable : ShieldFundsData
}
private const val DEFAULT_SHIELDING_THRESHOLD = 100000L

View File

@ -25,7 +25,7 @@ class ShieldFundsUseCase(
) {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
operator fun invoke(navigateBackAfterSuccess: Boolean) {
operator fun invoke(closeCurrentScreen: Boolean) {
scope.launch {
when (accountDataSource.getSelectedAccount()) {
is KeystoneAccount -> {
@ -33,7 +33,7 @@ class ShieldFundsUseCase(
}
is ZashiAccount -> {
if (navigateBackAfterSuccess) {
if (closeCurrentScreen) {
navigationRouter.back()
}
shieldZashiFunds()

View File

@ -2,7 +2,7 @@ package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.screen.balances.BalanceState
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetState
object BalanceStateFixture {
private const val BALANCE_VALUE = 0L
@ -12,9 +12,10 @@ object BalanceStateFixture {
fun new(
totalBalance: Zatoshi = TOTAL_BALANCE,
exchangeRate: ExchangeRateState = ObserveFiatCurrencyResultFixture.new()
) = BalanceState(
) = BalanceWidgetState(
totalBalance = totalBalance,
exchangeRate = exchangeRate,
button = null
button = null,
showDust = true
)
}

View File

@ -1,44 +0,0 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
class BalanceViewModel(
accountDataSource: AccountDataSource,
exchangeRateRepository: ExchangeRateRepository,
) : ViewModel() {
val state: StateFlow<BalanceState> =
combine(
accountDataSource.selectedAccount.filterNotNull(),
exchangeRateRepository.state,
) { account, exchangeRateUsd ->
createState(account, exchangeRateUsd)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
createState(
account = accountDataSource.allAccounts.value?.firstOrNull { it.isSelected },
exchangeRateUsd = exchangeRateRepository.state.value
)
)
private fun createState(account: WalletAccount?, exchangeRateUsd: ExchangeRateState): BalanceState {
return BalanceState(
totalBalance = account?.totalBalance ?: Zatoshi(0),
exchangeRate = exchangeRateUsd,
button = null
)
}
}

View File

@ -34,7 +34,7 @@ import co.electriccoin.zcash.ui.screen.balances.BalanceTag.BALANCE_VIEWS
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeBalance
@Composable
fun BalanceWidget(state: BalanceState, modifier: Modifier = Modifier) {
fun BalanceWidget(state: BalanceWidgetState, modifier: Modifier = Modifier) {
Column(
modifier =
Modifier
@ -44,12 +44,13 @@ fun BalanceWidget(state: BalanceState, modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally
) {
BalanceWidgetHeader(
parts = state.totalBalance.toZecStringFull().asZecAmountTriple()
parts = state.totalBalance.toZecStringFull().asZecAmountTriple(),
showDust = state.showDust
)
state.button?.let {
Spacer(12.dp)
BalanceButton(it)
BalanceWidgetButton(it)
}
state.exchangeRate?.let {
@ -57,9 +58,6 @@ fun BalanceWidget(state: BalanceState, modifier: Modifier = Modifier) {
Spacer(12.dp)
}
StyledExchangeBalance(state = it, zatoshi = state.totalBalance)
if (state.exchangeRate is ExchangeRateState.Data) {
Spacer(12.dp)
}
}
}
}
@ -69,6 +67,7 @@ fun BalanceWidgetHeader(
parts: ZecAmountTriple,
modifier: Modifier = Modifier,
isHideBalances: Boolean = LocalBalancesAvailable.current.not(),
showDust: Boolean = true,
) {
Row(
modifier = modifier,
@ -81,6 +80,7 @@ fun BalanceWidgetHeader(
)
Spacer(6.dp)
StyledBalance(
showDust = showDust,
balanceParts = parts,
isHideBalances = isHideBalances,
textStyle =
@ -101,7 +101,7 @@ private fun BalanceWidgetPreview() {
) {
BalanceWidget(
state =
BalanceState(
BalanceWidgetState(
totalBalance = Zatoshi(1234567891234567L),
button = BalanceButtonState(
icon = R.drawable.ic_help,
@ -109,7 +109,8 @@ private fun BalanceWidgetPreview() {
amount = Zatoshi(1000),
onClick = {}
),
exchangeRate = ObserveFiatCurrencyResultFixture.new()
exchangeRate = ObserveFiatCurrencyResultFixture.new(),
showDust = true
),
modifier = Modifier,
)

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues
@ -11,6 +12,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -30,12 +32,12 @@ import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensions
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
@Suppress("LongParameterList")
@Composable
fun BalanceButton(
internal fun BalanceWidgetButton(
state: BalanceButtonState,
modifier: Modifier = Modifier,
) {
@ -52,7 +54,7 @@ fun BalanceButton(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
colors = colors.toButtonColors(),
elevation = ButtonDefaults.buttonElevation(
defaultElevation = 2.dp,
defaultElevation = 1.dp,
pressedElevation = 0.dp
),
border = borderColor.takeIf { it != Color.Unspecified }?.let { BorderStroke(1.dp, it) },
@ -98,11 +100,19 @@ fun BalanceButton(
)
}
@Immutable
data class BalanceButtonState(
@DrawableRes val icon: Int,
val text: StringResource,
val amount: Zatoshi?,
val onClick: () -> Unit
)
@PreviewScreens
@Composable
private fun Preview() = ZcashTheme {
BlankSurface {
BalanceButton(
BalanceWidgetButton(
state = BalanceButtonState(
icon = R.drawable.ic_help,
text = stringRes("text"),

View File

@ -1,22 +1,13 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.util.StringResource
@Immutable
data class BalanceState(
data class BalanceWidgetState(
val showDust: Boolean,
val totalBalance: Zatoshi,
val button: BalanceButtonState?,
val exchangeRate: ExchangeRateState?,
)
@Immutable
data class BalanceButtonState(
@DrawableRes val icon: Int,
val text: StringResource,
val amount: Zatoshi?,
val onClick: () -> Unit
)

View File

@ -0,0 +1,81 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.balances.action.BalanceAction
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
class BalanceWidgetViewModel(
private val args: BalanceWidgetArgs,
accountDataSource: AccountDataSource,
exchangeRateRepository: ExchangeRateRepository,
private val navigationRouter: NavigationRouter,
) : ViewModel() {
val state: StateFlow<BalanceWidgetState> =
combine(
accountDataSource.selectedAccount.filterNotNull(),
exchangeRateRepository.state,
) { account, exchangeRateUsd ->
createState(account, exchangeRateUsd)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = createState(
account = accountDataSource.allAccounts.value?.firstOrNull { it.isSelected },
exchangeRateUsd = exchangeRateRepository.state.value
)
)
private fun createState(account: WalletAccount?, exchangeRateUsd: ExchangeRateState): BalanceWidgetState {
return BalanceWidgetState(
totalBalance = account?.totalBalance ?: Zatoshi(0),
exchangeRate = if (args.isExchangeRateButtonEnabled) exchangeRateUsd else null,
button = when {
!args.isBalanceButtonEnabled -> null
account == null -> null
account.totalBalance == account.spendableBalance -> null
account.isProcessingZeroAvailableBalance() && !account.isPending ->
BalanceButtonState(
icon = R.drawable.ic_balances_expand,
text = stringRes(R.string.widget_balances_button_spendable),
amount = null,
onClick = ::onBalanceButtonClick
)
account.totalBalance > account.spendableBalance -> BalanceButtonState(
icon = R.drawable.ic_balances_expand,
text = stringRes(R.string.widget_balances_button_spendable),
amount = account.spendableBalance,
onClick = ::onBalanceButtonClick
)
else -> null
},
showDust = args.showDust
)
}
private fun onBalanceButtonClick() = navigationRouter.forward(BalanceAction)
}
data class BalanceWidgetArgs(
val showDust: Boolean,
val isBalanceButtonEnabled: Boolean,
val isExchangeRateButtonEnabled: Boolean,
)

View File

@ -1,39 +1,108 @@
package co.electriccoin.zcash.ui.screen.balances.action
import androidx.lifecycle.ViewModel
import cash.z.ecc.android.sdk.model.Zatoshi
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.usecase.ShieldFundsUseCase
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.imageRes
import co.electriccoin.zcash.ui.design.util.loadingImageRes
import co.electriccoin.zcash.ui.design.util.stringRes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
class BalanceActionViewModel : ViewModel() {
val state = MutableStateFlow(
BalanceActionState(
title = stringRes("Error"),
message = stringRes("Something went wrong"),
positive = ButtonState(
text = stringRes("Positive")
),
onBack = {},
rows = listOf(
BalanceActionRowState(
title = stringRes("Row"),
icon = loadingImageRes(),
value = stringRes("Value")
),
BalanceActionRowState(
title = stringRes("Row"),
icon = imageRes(R.drawable.ic_home_buy),
value = stringRes("Value")
)
),
shieldButton = BalanceShieldButtonState(
amount = Zatoshi(10000),
onShieldClick = {}
)
class BalanceActionViewModel(
accountDataSource: AccountDataSource,
private val navigationRouter: NavigationRouter,
private val shieldFunds: ShieldFundsUseCase,
) : ViewModel() {
val state = accountDataSource.selectedAccount
.mapNotNull {
createState(it)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = createState(accountDataSource.allAccounts.value.orEmpty().firstOrNull { it.isSelected })
)
private fun createState(account: WalletAccount?): BalanceActionState? {
if (account == null) return null
return BalanceActionState(
title = stringRes("Spendable Balance"),
message = createMessage(account),
positive = createPositiveButton(account),
onBack = ::onBack,
rows = createInfoRows(account),
shieldButton = createShieldButtonState(account)
)
}
private fun createMessage(account: WalletAccount): StringResource {
val pending = when {
account.totalBalance == account.spendableBalance && !account.isPending ->
stringRes("All your funds are shielded and spendable.")
account.isPending || account.isProcessingZeroAvailableBalance() ->
stringRes("Pending transactions are getting mined and confirmed.")
else -> null
}
val shielding =
stringRes("Shield your transparent ZEC to make it spendable and private. Shielding transparent funds will create a shielding in-wallet transaction, consolidating your transparent and shielded funds. (Typical fee: .001 ZEC)")
.takeIf { account.transparent.isShieldingAvailable }
return if (pending != null && shielding != null) {
pending + stringRes("\n\n") + shielding
} else {
listOfNotNull(pending, shielding).reduceOrNull { acc, stringResource -> acc + stringResource } ?: stringRes(
""
)
}
}
private fun createPositiveButton(account: WalletAccount) = ButtonState(
text = if (account.transparent.isShieldingAvailable) stringRes("Dismiss") else stringRes("Ok"),
onClick = ::onBack
)
private fun createInfoRows(account: WalletAccount) = listOfNotNull(
BalanceActionRowState(
title = stringRes("Shielded ZEC (Spendable)"),
icon = imageRes(R.drawable.ic_balance_shield),
value = stringRes(R.string.general_zec, stringRes(account.spendableBalance))
),
if (!account.isProcessingZeroAvailableBalance()) {
BalanceActionRowState(
title = stringRes("Pending"),
icon = loadingImageRes(),
value = stringRes(R.string.general_zec, stringRes(account.totalBalance))
)
} else {
BalanceActionRowState(
title = stringRes("Pending"),
icon = loadingImageRes(),
value = stringRes(R.string.general_zec, stringRes(account.pendingBalance))
).takeIf { account.isPending }
},
)
private fun createShieldButtonState(account: WalletAccount): BalanceShieldButtonState? {
return BalanceShieldButtonState(
amount = account.transparent.balance,
onShieldClick = ::onShieldClick
).takeIf { account.transparent.isShieldingAvailable }
}
private fun onBack() = navigationRouter.back()
private fun onShieldClick() = shieldFunds(closeCurrentScreen = true)
}

View File

@ -28,7 +28,7 @@ class ExchangeRateSettingsViewModel(
private fun createState(it: ExchangeRateState) =
ExchangeRateSettingsState(
isOptedIn = it is ExchangeRateState.OptIn,
isOptedIn = it is ExchangeRateState.Data,
onSaveClick = ::onOptInExchangeRateUsdClick,
onDismiss = ::onBack
)

View File

@ -7,28 +7,38 @@ import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarViewModel
import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetArgs
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.WrapRestoreSuccess
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetViewModel
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
internal fun AndroidHome() {
val topAppBarViewModel = koinActivityViewModel<ZashiTopAppBarViewModel>()
val balanceViewModel = koinViewModel<BalanceViewModel>()
val balanceWidgetViewModel = koinViewModel<BalanceWidgetViewModel> {
parametersOf(
BalanceWidgetArgs(
isBalanceButtonEnabled = false,
isExchangeRateButtonEnabled = true,
showDust = false,
)
)
}
val homeViewModel = koinViewModel<HomeViewModel>()
val transactionHistoryWidgetViewModel = koinViewModel<TransactionHistoryWidgetViewModel>()
val restoreDialogState by homeViewModel.restoreDialogState.collectAsStateWithLifecycle()
val appBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
val balanceState by balanceViewModel.state.collectAsStateWithLifecycle()
val balanceState by balanceWidgetViewModel.state.collectAsStateWithLifecycle()
val state by homeViewModel.state.collectAsStateWithLifecycle()
val transactionWidgetState by transactionHistoryWidgetViewModel.state.collectAsStateWithLifecycle()
state?.let {
HomeView(
appBarState = appBarState,
balanceState = balanceState,
balanceWidgetState = balanceState,
state = it,
transactionWidgetState = transactionWidgetState
)

View File

@ -23,14 +23,16 @@ import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarWithAccountSelection
import co.electriccoin.zcash.ui.design.component.BigIconButtonState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.Spacer
import co.electriccoin.zcash.ui.design.component.ZashiBigIconButton
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensions
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.balances.BalanceState
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetState
import co.electriccoin.zcash.ui.screen.balances.BalanceWidget
import co.electriccoin.zcash.ui.screen.home.error.WalletErrorMessageState
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetState
@ -40,7 +42,7 @@ import co.electriccoin.zcash.ui.screen.transactionhistory.widget.createTransacti
@Composable
internal fun HomeView(
appBarState: ZashiMainTopAppBarState?,
balanceState: BalanceState,
balanceWidgetState: BalanceWidgetState,
transactionWidgetState: TransactionHistoryWidgetState,
state: HomeState
) {
@ -48,10 +50,10 @@ internal fun HomeView(
topBar = { ZashiTopAppBarWithAccountSelection(appBarState) }
) { paddingValues ->
Content(
modifier = Modifier.padding(top = paddingValues.calculateTopPadding() + 24.dp),
modifier = Modifier.padding(top = paddingValues.calculateTopPadding() + ZashiDimensions.Spacing.spacingLg),
paddingValues = paddingValues,
transactionHistoryWidgetState = transactionWidgetState,
balanceState = balanceState,
balanceWidgetState = balanceWidgetState,
state = state
)
}
@ -61,7 +63,7 @@ internal fun HomeView(
private fun Content(
transactionHistoryWidgetState: TransactionHistoryWidgetState,
paddingValues: PaddingValues,
balanceState: BalanceState,
balanceWidgetState: BalanceWidgetState,
state: HomeState,
modifier: Modifier = Modifier,
) {
@ -71,7 +73,7 @@ private fun Content(
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
Spacer(8.dp)
BalanceWidget(
modifier =
Modifier
@ -79,8 +81,9 @@ private fun Content(
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular,
),
state = balanceState,
state = balanceWidgetState,
)
Spacer(16.dp)
NavButtons(
modifier =
Modifier
@ -160,7 +163,7 @@ private fun Preview() {
ZcashTheme {
HomeView(
appBarState = ZashiMainTopAppBarStateFixture.new(),
balanceState = BalanceStateFixture.new(),
balanceWidgetState = BalanceStateFixture.new(),
transactionWidgetState = TransactionHistoryWidgetStateFixture.new(),
state =
HomeState(

View File

@ -237,7 +237,7 @@ class HomeViewModel(
private fun onShieldFundsMessageClick() = navigationRouter.forward(ShieldFundsInfo)
private fun onShieldFundsMessageButtonClick() = shieldFunds(navigateBackAfterSuccess = false)
private fun onShieldFundsMessageButtonClick() = shieldFunds(closeCurrentScreen = false)
private fun onWalletErrorMessageClick(homeMessageData: HomeMessageData.Error) =
navigateToError(ErrorArgs.SyncError(homeMessageData.synchronizerError))

View File

@ -74,6 +74,6 @@ class ShieldFundsInfoViewModel(
private fun onBack() = navigationRouter.back()
private fun onShieldClick() = shieldFunds(navigateBackAfterSuccess = true)
private fun onShieldClick() = shieldFunds(closeCurrentScreen = true)
}

View File

@ -32,8 +32,9 @@ import co.electriccoin.zcash.ui.common.usecase.PrefillSendUseCase
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.balances.BalanceState
import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetArgs
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetState
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetViewModel
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.ScanFlow
import co.electriccoin.zcash.ui.screen.send.ext.Saver
@ -45,6 +46,7 @@ import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
import java.util.Locale
@Composable
@ -55,7 +57,16 @@ internal fun WrapSend(args: Send) {
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val balanceViewModel = koinViewModel<BalanceViewModel>()
val balanceWidgetViewModel =
koinViewModel<BalanceWidgetViewModel> {
parametersOf(
BalanceWidgetArgs(
isBalanceButtonEnabled = true,
isExchangeRateButtonEnabled = false,
showDust = true
)
)
}
val accountDataSource = koinInject<AccountDataSource>()
@ -69,12 +80,12 @@ internal fun WrapSend(args: Send) {
val monetarySeparators = MonetarySeparators.current(Locale.getDefault())
val balanceState = balanceViewModel.state.collectAsStateWithLifecycle().value
val balanceState = balanceWidgetViewModel.state.collectAsStateWithLifecycle().value
val exchangeRateState = exchangeRateRepository.state.collectAsStateWithLifecycle().value
WrapSend(
balanceState = balanceState,
balanceWidgetState = balanceState,
exchangeRateState = exchangeRateState,
goToQrScanner = {
navigationRouter.forward(
@ -97,7 +108,7 @@ internal fun WrapSend(args: Send) {
@VisibleForTesting
@Composable
internal fun WrapSend(
balanceState: BalanceState,
balanceWidgetState: BalanceWidgetState,
exchangeRateState: ExchangeRateState,
goToQrScanner: () -> Unit,
goBack: () -> Unit,
@ -286,7 +297,7 @@ internal fun WrapSend(
CircularScreenProgressIndicator()
} else {
Send(
balanceState = balanceState,
balanceWidgetState = balanceWidgetState,
sendStage = sendStage,
onCreateZecSend = { newZecSend ->
viewModel.onCreateZecSendClick(

View File

@ -64,6 +64,7 @@ import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.Spacer
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.ZashiTextFieldDefaults
@ -74,7 +75,7 @@ import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.balances.BalanceState
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetState
import co.electriccoin.zcash.ui.screen.balances.BalanceWidget
import co.electriccoin.zcash.ui.screen.send.SendTag
import co.electriccoin.zcash.ui.screen.send.model.AmountState
@ -90,7 +91,7 @@ import java.util.Locale
private fun PreviewSendForm() {
ZcashTheme(forceDarkMode = false) {
Send(
balanceState = BalanceStateFixture.new(),
balanceWidgetState = BalanceStateFixture.new(),
sendStage = SendStage.Form,
onCreateZecSend = {},
onBack = {},
@ -125,7 +126,7 @@ private fun PreviewSendForm() {
private fun SendFormTransparentAddressPreview() {
ZcashTheme(forceDarkMode = false) {
Send(
balanceState = BalanceStateFixture.new(),
balanceWidgetState = BalanceStateFixture.new(),
sendStage = SendStage.Form,
onCreateZecSend = {},
onBack = {},
@ -164,7 +165,7 @@ private fun SendFormTransparentAddressPreview() {
@Suppress("LongParameterList")
@Composable
fun Send(
balanceState: BalanceState,
balanceWidgetState: BalanceWidgetState,
sendStage: SendStage,
onCreateZecSend: (ZecSend) -> Unit,
onBack: () -> Unit,
@ -193,7 +194,7 @@ fun Send(
)
}) { paddingValues ->
SendMainContent(
balanceState = balanceState,
balanceWidgetState = balanceWidgetState,
selectedAccount = selectedAccount,
exchangeRateState = exchangeRateState,
onBack = onBack,
@ -216,7 +217,7 @@ fun Send(
@Suppress("LongParameterList")
@Composable
private fun SendMainContent(
balanceState: BalanceState,
balanceWidgetState: BalanceWidgetState,
selectedAccount: WalletAccount,
exchangeRateState: ExchangeRateState,
onBack: () -> Unit,
@ -237,7 +238,7 @@ private fun SendMainContent(
// loader if calling the Proposal API takes longer than expected
SendForm(
balanceState = balanceState,
balanceWidgetState = balanceWidgetState,
selectedAccount = selectedAccount,
recipientAddressState = recipientAddressState,
exchangeRateState = exchangeRateState,
@ -270,7 +271,7 @@ private fun SendMainContent(
@Suppress("LongParameterList", "LongMethod")
@Composable
private fun SendForm(
balanceState: BalanceState,
balanceWidgetState: BalanceWidgetState,
selectedAccount: WalletAccount,
recipientAddressState: RecipientAddressState,
exchangeRateState: ExchangeRateState,
@ -295,13 +296,13 @@ private fun SendForm(
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
Spacer(8.dp)
BalanceWidget(
state = balanceState
state = balanceWidgetState
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(24.dp)
// TODO [#1256]: Consider Send.Form TextFields scrolling
// TODO [#1256]: https://github.com/Electric-Coin-Company/zashi-android/issues/1256

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M10.239,0.908C10.08,0.885 9.92,0.885 9.762,0.908C9.58,0.934 9.41,0.998 9.276,1.049L9.239,1.063L4.662,2.779C4.148,2.971 3.695,3.14 3.347,3.447C3.043,3.715 2.808,4.054 2.664,4.433C2.499,4.867 2.499,5.35 2.5,5.899L2.5,10.001C2.5,12.356 3.781,14.32 5.166,15.763C6.56,17.215 8.155,18.241 8.999,18.733L9.033,18.753C9.187,18.843 9.386,18.961 9.651,19.017C9.867,19.064 10.134,19.064 10.35,19.017C10.614,18.961 10.814,18.843 10.968,18.753L11.002,18.733C11.845,18.241 13.44,17.215 14.834,15.763C16.219,14.32 17.5,12.356 17.5,10.001L17.5,5.899C17.501,5.35 17.502,4.867 17.337,4.433C17.192,4.054 16.958,3.715 16.653,3.447C16.305,3.14 15.853,2.971 15.338,2.779L10.761,1.063L10.725,1.049C10.59,0.998 10.421,0.934 10.239,0.908Z"
android:fillColor="#231F20"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="17dp"
android:height="16dp"
android:viewportWidth="17"
android:viewportHeight="16">
<path
android:pathData="M9.833,6.667L14.5,2M14.5,2H10.5M14.5,2V6M7.167,9.333L2.5,14M2.5,14H6.5M2.5,14L2.5,10"
android:strokeLineJoin="round"
android:strokeWidth="1.33333"
android:fillColor="#00000000"
android:strokeColor="#716C5D"
android:strokeLineCap="round"/>
</vector>

View File

@ -2,4 +2,5 @@
<resources>
<string name="balance_action_shield">Shield</string>
<string name="balance_action_shield_button_header">Transparent</string>
<string name="widget_balances_button_spendable">Spendable</string>
</resources>

View File

@ -2,4 +2,5 @@
<resources>
<string name="balance_action_shield">Shield</string>
<string name="balance_action_shield_button_header">Transparent</string>
<string name="widget_balances_button_spendable">Spendable</string>
</resources>

View File

@ -35,4 +35,6 @@
<string name="general_remind_me_in_two_days">two days</string>
<string name="general_remind_me_in_two_weeks">two weeks</string>
<string name="general_remind_me_in_two_months">two months</string>
<string name="general_zec">%S ZEC</string>
</resources>