Home redesign

This commit is contained in:
Milan Cerovsky 2025-03-03 09:27:09 +01:00
parent ec4bc7784f
commit 3ff1df9d0d
113 changed files with 1964 additions and 4167 deletions

View File

@ -1,7 +1,6 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
@ -62,7 +61,6 @@ private fun HiddenStyledBalancePreview() =
* @param textColor Optional color to modify the default font color from [textStyle]
* @param modifier Modifier to modify the Text UI element as needed
*/
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList")
@Composable
fun StyledBalance(

View File

@ -0,0 +1,69 @@
package co.electriccoin.zcash.ui.design.component
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
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
@Composable
fun ZashiBigIconButton(
state: BigIconButtonState,
modifier: Modifier = Modifier,
) {
Surface(
modifier =
modifier
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = state.onClick,
role = Role.Button,
),
shape = RoundedCornerShape(16.dp),
color = ZashiColors.Surfaces.bgSecondary
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(state.icon),
contentDescription = state.text.getValue(),
tint = ZashiColors.Text.textPrimary
)
Spacer(Modifier.height(4.dp))
Text(
text = state.text.getValue(),
style = ZashiTypography.textXs,
fontWeight = FontWeight.Medium,
color = ZashiColors.Text.textPrimary
)
}
}
}
data class BigIconButtonState(
val text: StringResource,
@DrawableRes val icon: Int,
val onClick: () -> Unit,
)

View File

@ -4,7 +4,7 @@ import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.common.model.SerializableAddress
import co.electriccoin.zcash.ui.screen.send.model.SendArguments
import co.electriccoin.zcash.ui.screen.send.Send
internal object SendArgumentsWrapperFixture {
val RECIPIENT_ADDRESS =
@ -14,7 +14,7 @@ internal object SendArgumentsWrapperFixture {
)
fun new(recipientAddress: SerializableAddress? = RECIPIENT_ADDRESS) =
SendArguments(
Send(
recipientAddress = recipientAddress?.toRecipient(),
)
}

View File

@ -1,90 +0,0 @@
package co.electriccoin.zcash.ui.screen.account
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.account.view.Account
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetStateFixture
import java.util.concurrent.atomic.AtomicInteger
class AccountTestSetup(
private val composeTestRule: ComposeContentTestRule,
private val walletSnapshot: WalletSnapshot,
) {
// TODO [#1282]: Update AccountView Tests #1282
// TODO [#1282]: https://github.com/Electric-Coin-Company/zashi-android/issues/1282
private val onSettingsCount = AtomicInteger(0)
private val onHideBalancesCount = AtomicInteger(0)
fun getOnSettingsCount(): Int {
composeTestRule.waitForIdle()
return onSettingsCount.get()
}
fun getOnHideBalancesCount(): Int {
composeTestRule.waitForIdle()
return onHideBalancesCount.get()
}
fun getWalletSnapshot(): WalletSnapshot {
composeTestRule.waitForIdle()
return walletSnapshot
}
@Composable
@Suppress("TestFunctionName")
fun DefaultContent(isHideBalances: Boolean) {
Account(
balanceState = BalanceStateFixture.new(),
goBalances = {},
hideStatusDialog = {},
isHideBalances = isHideBalances,
onContactSupport = {},
showStatusDialog = null,
snackbarHostState = SnackbarHostState(),
zashiMainTopAppBarState =
ZashiMainTopAppBarStateFixture.new(
settingsButton =
IconButtonState(
icon = R.drawable.ic_app_bar_settings,
contentDescription =
stringRes(co.electriccoin.zcash.ui.R.string.settings_menu_content_description),
) {
onSettingsCount.incrementAndGet()
},
balanceVisibilityButton =
IconButtonState(
icon = R.drawable.ic_app_bar_balances_hide,
contentDescription =
stringRes(
co.electriccoin.zcash.ui.R.string.hide_balances_content_description
),
) {
onHideBalancesCount.incrementAndGet()
},
),
transactionHistoryWidgetState = TransactionHistoryWidgetStateFixture.new(),
isWalletRestoringState = WalletRestoringState.NONE,
walletSnapshot = WalletSnapshotFixture.new(),
onStatusClick = {},
)
}
fun setDefaultContent(isHideBalances: Boolean = false) {
composeTestRule.setContent {
ZcashTheme {
DefaultContent(isHideBalances)
}
}
}
}

View File

@ -1,64 +0,0 @@
package co.electriccoin.zcash.ui.screen.account.integration
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.account.AccountTag
import co.electriccoin.zcash.ui.screen.account.AccountTestSetup
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class AccountViewIntegrationTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
private fun newTestSetup(walletSnapshot: WalletSnapshot) =
AccountTestSetup(
composeTestRule,
walletSnapshot,
)
// This is just basic sanity check that we still have UI set up as expected after the state restore
@Test
@MediumTest
fun wallet_snapshot_restoration() {
val restorationTester = StateRestorationTester(composeTestRule)
val walletSnapshot =
WalletSnapshotFixture.new(
saplingBalance = WalletSnapshotFixture.SAPLING_BALANCE,
orchardBalance = WalletSnapshotFixture.ORCHARD_BALANCE,
transparentBalance = WalletSnapshotFixture.TRANSPARENT_BALANCE
)
val testSetup = newTestSetup(walletSnapshot)
restorationTester.setContent {
ZcashTheme {
testSetup.DefaultContent(isHideBalances = false)
}
}
assertEquals(WalletSnapshotFixture.SAPLING_BALANCE, testSetup.getWalletSnapshot().saplingBalance)
assertEquals(WalletSnapshotFixture.ORCHARD_BALANCE, testSetup.getWalletSnapshot().orchardBalance)
assertEquals(WalletSnapshotFixture.TRANSPARENT_BALANCE, testSetup.getWalletSnapshot().transparentBalance)
restorationTester.emulateSavedInstanceStateRestore()
assertEquals(WalletSnapshotFixture.SAPLING_BALANCE, testSetup.getWalletSnapshot().saplingBalance)
assertEquals(WalletSnapshotFixture.ORCHARD_BALANCE, testSetup.getWalletSnapshot().orchardBalance)
assertEquals(WalletSnapshotFixture.TRANSPARENT_BALANCE, testSetup.getWalletSnapshot().transparentBalance)
composeTestRule.onNodeWithTag(AccountTag.BALANCE_VIEWS).also {
it.assertIsDisplayed()
it.assertWidthIsAtLeast(1.dp)
}
}
}

View File

@ -1,74 +0,0 @@
package co.electriccoin.zcash.ui.screen.account.view
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.component.CommonTag
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.account.AccountTag
import co.electriccoin.zcash.ui.screen.account.AccountTestSetup
import co.electriccoin.zcash.ui.screen.send.clickHideBalances
import co.electriccoin.zcash.ui.screen.send.clickSettingsTopAppBarMenu
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
// TODO [#1194]: Cover Current balances UI widget with tests
// TODO [#1194]: https://github.com/Electric-Coin-Company/zashi-android/issues/1194
class AccountViewTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun check_all_elementary_ui_elements_displayed() {
newTestSetup()
composeTestRule.onNodeWithTag(CommonTag.TOP_APP_BAR)
.also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(AccountTag.BALANCE_VIEWS).also {
it.assertIsDisplayed()
}
}
@Test
@MediumTest
fun hamburger_settings_test() {
val testSetup = newTestSetup()
Assert.assertEquals(0, testSetup.getOnSettingsCount())
composeTestRule.clickSettingsTopAppBarMenu()
Assert.assertEquals(1, testSetup.getOnSettingsCount())
}
@Test
@MediumTest
fun hide_balances_btn_click_test() {
val testSetup = newTestSetup()
Assert.assertEquals(0, testSetup.getOnHideBalancesCount())
composeTestRule.clickHideBalances()
Assert.assertEquals(1, testSetup.getOnHideBalancesCount())
}
private fun newTestSetup(
walletSnapshot: WalletSnapshot = WalletSnapshotFixture.new(),
isHideBalances: Boolean = false
) = AccountTestSetup(
composeTestRule,
walletSnapshot = walletSnapshot,
).apply {
setDefaultContent(isHideBalances)
}
}

View File

@ -1,72 +0,0 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
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.model.ShieldState
import co.electriccoin.zcash.ui.screen.balances.view.Balances
import java.util.concurrent.atomic.AtomicInteger
class BalancesTestSetup(
private val composeTestRule: ComposeContentTestRule,
private val walletSnapshot: WalletSnapshot,
) {
private val onSettingsCount = AtomicInteger(0)
fun getOnSettingsCount(): Int {
composeTestRule.waitForIdle()
return onSettingsCount.get()
}
fun getWalletSnapshot(): WalletSnapshot {
composeTestRule.waitForIdle()
return walletSnapshot
}
@Composable
@Suppress("TestFunctionName")
fun DefaultContent() {
Balances(
balanceState = BalanceStateFixture.new(),
hideStatusDialog = {},
isHideBalances = false,
showStatusDialog = null,
onStatusClick = {},
snackbarHostState = SnackbarHostState(),
isShowingErrorDialog = false,
setShowErrorDialog = {},
onContactSupport = {},
onShielding = {},
shieldState = ShieldState.Available,
walletSnapshot = walletSnapshot,
walletRestoringState = WalletRestoringState.NONE,
zashiMainTopAppBarState =
ZashiMainTopAppBarStateFixture.new(
settingsButton =
IconButtonState(
icon = R.drawable.ic_app_bar_settings,
contentDescription =
stringRes(co.electriccoin.zcash.ui.R.string.settings_menu_content_description),
) {
onSettingsCount.incrementAndGet()
}
)
)
}
fun setDefaultContent() {
composeTestRule.setContent {
ZcashTheme {
DefaultContent()
}
}
}
}

View File

@ -1,69 +0,0 @@
package co.electriccoin.zcash.ui.screen.balances.integration
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.PercentDecimal
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.balances.BalancesTag
import co.electriccoin.zcash.ui.screen.balances.BalancesTestSetup
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
class BalancesViewIntegrationTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
private fun newTestSetup(walletSnapshot: WalletSnapshot) =
BalancesTestSetup(
composeTestRule,
walletSnapshot,
)
// This is just basic sanity check that we still have UI set up as expected after the state restore
@Test
@MediumTest
fun wallet_snapshot_restoration() {
val restorationTester = StateRestorationTester(composeTestRule)
val walletSnapshot =
WalletSnapshotFixture.new(
status = Synchronizer.Status.SYNCING,
progress = PercentDecimal(0.5f)
)
val testSetup = newTestSetup(walletSnapshot)
restorationTester.setContent {
ZcashTheme {
testSetup.DefaultContent()
}
}
assertNotEquals(WalletSnapshotFixture.STATUS, testSetup.getWalletSnapshot().status)
assertEquals(Synchronizer.Status.SYNCING, testSetup.getWalletSnapshot().status)
assertNotEquals(WalletSnapshotFixture.PROGRESS, testSetup.getWalletSnapshot().progress)
assertEquals(0.5f, testSetup.getWalletSnapshot().progress.decimal)
restorationTester.emulateSavedInstanceStateRestore()
assertNotEquals(WalletSnapshotFixture.STATUS, testSetup.getWalletSnapshot().status)
assertEquals(Synchronizer.Status.SYNCING, testSetup.getWalletSnapshot().status)
assertNotEquals(WalletSnapshotFixture.PROGRESS, testSetup.getWalletSnapshot().progress)
assertEquals(0.5f, testSetup.getWalletSnapshot().progress.decimal)
composeTestRule.onNodeWithTag(BalancesTag.STATUS).also {
it.assertExists()
it.assertWidthIsAtLeast(1.dp)
}
}
}

View File

@ -1,48 +0,0 @@
package co.electriccoin.zcash.ui.screen.balances.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.toZecString
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.totalBalance
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.test.getAppContext
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WalletDisplayValuesTest {
@Test
@SmallTest
fun download_running_test() {
val walletSnapshot =
WalletSnapshotFixture.new(
progress = PercentDecimal.ONE_HUNDRED_PERCENT,
status = Synchronizer.Status.SYNCING,
orchardBalance = WalletSnapshotFixture.ORCHARD_BALANCE,
saplingBalance = WalletSnapshotFixture.SAPLING_BALANCE,
transparentBalance = WalletSnapshotFixture.TRANSPARENT_BALANCE
)
val values =
WalletDisplayValues.getNextValues(
context = getAppContext(),
walletSnapshot = walletSnapshot,
)
assertNotNull(values)
assertEquals(1f, values.progress.decimal)
assertEquals(walletSnapshot.totalBalance().toZecString(), values.zecAmountText)
assertTrue(values.statusText.startsWith(getStringResource(R.string.balances_status_syncing)))
// TODO [#578]: Provide Zatoshi -> USD fiat currency formatting
// TODO [#578]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/578
assertEquals(FiatCurrencyConversionRateState.Unavailable, values.fiatCurrencyAmountState)
assertEquals(
getStringResource(R.string.fiat_currency_conversion_rate_unavailable),
values.fiatCurrencyAmountText
)
}
}

View File

@ -1,55 +0,0 @@
package co.electriccoin.zcash.ui.screen.balances.view
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.component.CommonTag
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.balances.BalancesTestSetup
import co.electriccoin.zcash.ui.screen.send.clickSettingsTopAppBarMenu
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import kotlin.test.DefaultAsserter.assertEquals
// TODO [#1227]: Cover Balances UI and logic with tests
// TODO [#1227]: https://github.com/Electric-Coin-Company/zashi-android/issues/1227
class BalancesViewTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
private fun newTestSetup(walletSnapshot: WalletSnapshot = WalletSnapshotFixture.new()) =
BalancesTestSetup(
composeTestRule,
walletSnapshot = walletSnapshot,
).apply {
setDefaultContent()
}
@Test
@MediumTest
fun check_all_elementary_ui_elements_displayed() {
newTestSetup()
composeTestRule.onNodeWithTag(CommonTag.TOP_APP_BAR)
.also {
it.assertIsDisplayed()
}
}
@Test
@MediumTest
fun hamburger_settings_test() {
val testSetup = newTestSetup()
assertEquals("Failed in comparison", 0, testSetup.getOnSettingsCount())
composeTestRule.clickSettingsTopAppBarMenu()
Assert.assertEquals(1, testSetup.getOnSettingsCount())
}
}

View File

@ -47,7 +47,7 @@ class ReceiveViewTestSetup(
),
isLoading = false,
),
zashiMainTopAppBarState =
appBarState =
ZashiMainTopAppBarStateFixture.new(
settingsButton =
IconButtonState(

View File

@ -114,11 +114,6 @@ class SendViewTestSetup(
onQrScannerOpen = {
onScannerCount.incrementAndGet()
},
goBalances = {
// TODO [#1194]: Cover Current balances UI widget with tests
// TODO [#1194]: https://github.com/Electric-Coin-Company/zashi-android/issues/1194
},
isHideBalances = false,
hasCameraFeature = hasCameraFeature,
recipientAddressState = RecipientAddressState("", AddressType.Invalid()),
onRecipientAddressChange = {
@ -137,7 +132,7 @@ class SendViewTestSetup(
),
setMemoState = {},
memoState = MemoState.new(""),
walletSnapshot =
selectedAccount =
WalletSnapshotFixture.new(
saplingBalance =
WalletBalanceFixture.new(

View File

@ -33,10 +33,9 @@ class SendViewIntegrationTest {
restorationTester.setContent {
WrapSend(
sendArguments = null,
args = null,
goToQrScanner = {},
goBack = {},
goBalances = {},
)
}

View File

@ -7,8 +7,6 @@ import co.electriccoin.zcash.global.newInstance
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
import co.electriccoin.zcash.ui.HomeTabNavigationRouter
import co.electriccoin.zcash.ui.HomeTabNavigationRouterImpl
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationRouterImpl
import co.electriccoin.zcash.ui.preference.PersistableWalletPreferenceDefault
@ -38,5 +36,4 @@ val coreModule =
factory { AndroidConfigurationFactory.new() }
singleOf(::NavigationRouterImpl) bind NavigationRouter::class
singleOf(::HomeTabNavigationRouterImpl) bind HomeTabNavigationRouter::class
}

View File

@ -1,7 +1,5 @@
package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.repository.BalanceRepository
import co.electriccoin.zcash.ui.common.repository.BalanceRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository
@ -29,7 +27,6 @@ val repositoryModule =
singleOf(::WalletRepositoryImpl) bind WalletRepository::class
singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class
singleOf(::ExchangeRateRepositoryImpl) bind ExchangeRateRepository::class
singleOf(::BalanceRepositoryImpl) bind BalanceRepository::class
singleOf(::FlexaRepositoryImpl) bind FlexaRepository::class
singleOf(::BiometricRepositoryImpl) bind BiometricRepository::class
singleOf(::KeystoneProposalRepositoryImpl) bind KeystoneProposalRepository::class

View File

@ -5,12 +5,12 @@ import co.electriccoin.zcash.ui.common.usecase.ApplyTransactionFulltextFiltersUs
import co.electriccoin.zcash.ui.common.usecase.CancelProposalFlowUseCase
import co.electriccoin.zcash.ui.common.usecase.ConfirmProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CopyToClipboardUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateFlexaTransactionUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneProposalPCZTEncoderUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneShieldProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateOrUpdateTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateZip321ProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.DeleteContactUseCase
import co.electriccoin.zcash.ui.common.usecase.DeleteTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.DeriveKeystoneAccountUnifiedAddressUseCase
@ -33,6 +33,7 @@ import co.electriccoin.zcash.ui.common.usecase.GetTransactionFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletStateInformationUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
@ -55,8 +56,9 @@ import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedWalletAccountUseCa
import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveTransactionSubmitStateUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveZashiAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.OnAddressScannedUseCase
import co.electriccoin.zcash.ui.common.usecase.OnZip321ScannedUseCase
import co.electriccoin.zcash.ui.common.usecase.ParseKeystonePCZTUseCase
import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneSignInRequestUseCase
import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneUrToZashiAccountsUseCase
@ -114,7 +116,7 @@ val useCaseModule =
factoryOf(::ShareImageUseCase)
factoryOf(::Zip321BuildUriUseCase)
factoryOf(::Zip321ParseUriValidationUseCase)
factoryOf(::ObserveWalletStateUseCase)
factoryOf(::GetWalletStateInformationUseCase)
factoryOf(::IsCoinbaseAvailableUseCase)
factoryOf(::GetZashiSpendingKeyUseCase)
factoryOf(::ObservePersistableWalletUseCase)
@ -138,7 +140,8 @@ val useCaseModule =
factoryOf(::GetCurrentTransactionsUseCase)
factoryOf(::GetCurrentFilteredTransactionsUseCase) onClose ::closeableCallback
factoryOf(::CreateProposalUseCase)
factoryOf(::CreateZip321ProposalUseCase)
factoryOf(::OnZip321ScannedUseCase)
factoryOf(::OnAddressScannedUseCase)
factoryOf(::CreateKeystoneShieldProposalUseCase)
factoryOf(::ParseKeystonePCZTUseCase)
factoryOf(::ParseKeystoneSignInRequestUseCase)
@ -172,4 +175,5 @@ val useCaseModule =
factoryOf(::GetMetadataUseCase)
factoryOf(::ExportTaxUseCase)
factoryOf(::NavigateToTaxExportUseCase)
factoryOf(::CreateFlexaTransactionUseCase)
}

View File

@ -1,17 +1,20 @@
package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarViewModel
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.OldHomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
import co.electriccoin.zcash.ui.screen.accountlist.viewmodel.AccountListViewModel
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.viewmodel.AdvancedSettingsViewModel
import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel
import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
import co.electriccoin.zcash.ui.screen.feedback.viewmodel.FeedbackViewModel
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import co.electriccoin.zcash.ui.screen.home.HomeViewModel
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel
@ -20,7 +23,7 @@ import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransactionViewModel
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel
import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystonePCZTViewModel
import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystoneSignInRequestViewModel
@ -53,7 +56,7 @@ val viewModelModule =
module {
viewModelOf(::WalletViewModel)
viewModelOf(::AuthenticationViewModel)
viewModelOf(::HomeViewModel)
viewModelOf(::OldHomeViewModel)
viewModelOf(::OnboardingViewModel)
viewModelOf(::StorageCheckViewModel)
viewModelOf(::RestoreViewModel)
@ -78,17 +81,30 @@ val viewModelModule =
viewModelOf(::ReceiveViewModel)
viewModelOf(::QrCodeViewModel)
viewModelOf(::RequestViewModel)
viewModelOf(::IntegrationsViewModel)
viewModel { (args: ScanNavigationArgs) ->
viewModel { (args: Scan) ->
ScanViewModel(
args = args,
getSynchronizer = get(),
zip321ParseUriValidationUseCase = get(),
onAddressScanned = get(),
zip321Scanned = get()
)
}
viewModelOf(::ScanKeystoneSignInRequestViewModel)
viewModelOf(::ScanKeystonePCZTViewModel)
viewModelOf(::IntegrationsViewModel)
viewModel { (isDialog: Boolean) ->
IntegrationsViewModel(
isDialog = isDialog,
getZcashCurrency = get(),
isFlexaAvailableUseCase = get(),
isCoinbaseAvailable = get(),
observeWalletAccounts = get(),
navigationRouter = get(),
navigateToCoinbase = get(),
getWalletRestoringState = get()
)
}
viewModelOf(::FlexaViewModel)
viewModelOf(::SendViewModel)
viewModel { (args: SeedNavigationArgs) ->
SeedViewModel(
@ -100,7 +116,7 @@ val viewModelModule =
viewModelOf(::FeedbackViewModel)
viewModelOf(::SignKeystoneTransactionViewModel)
viewModelOf(::AccountListViewModel)
viewModelOf(::ZashiMainTopAppBarViewModel)
viewModelOf(::ZashiTopAppBarViewModel)
viewModel { (args: SelectKeystoneAccount) ->
SelectKeystoneAccountViewModel(
args = args,
@ -138,4 +154,6 @@ val viewModelModule =
)
}
viewModelOf(::TaxExportViewModel)
viewModelOf(::BalanceViewModel)
viewModelOf(::HomeViewModel)
}

View File

@ -1,30 +0,0 @@
package co.electriccoin.zcash.ui
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
interface HomeTabNavigationRouter {
fun select(tab: HomeScreenIndex)
fun observe(): Flow<HomeScreenIndex>
}
class HomeTabNavigationRouterImpl : HomeTabNavigationRouter {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val channel = Channel<HomeScreenIndex>()
override fun select(tab: HomeScreenIndex) {
scope.launch {
channel.send(tab)
}
}
override fun observe() = channel.receiveAsFlow()
}

View File

@ -35,7 +35,7 @@ import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationUIState
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.OldHomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.configuration.RemoteConfig
@ -67,7 +67,7 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class MainActivity : FragmentActivity() {
private val homeViewModel by viewModel<HomeViewModel>()
private val oldHomeViewModel by viewModel<OldHomeViewModel>()
val walletViewModel by viewModel<WalletViewModel>()
@ -132,7 +132,7 @@ class MainActivity : FragmentActivity() {
}
// Note this condition needs to be kept in sync with the condition in MainContent()
homeViewModel.configurationFlow.value == null || SecretState.Loading == walletViewModel.secretState.value
oldHomeViewModel.configurationFlow.value == null || SecretState.Loading == walletViewModel.secretState.value
}
}
@ -143,7 +143,7 @@ class MainActivity : FragmentActivity() {
enableEdgeToEdge()
setContentCompat {
Override(configurationOverrideFlow) {
val isHideBalances by homeViewModel.isHideBalances.collectAsStateWithLifecycle()
val isHideBalances by oldHomeViewModel.isHideBalances.collectAsStateWithLifecycle()
ZcashTheme(
balancesAvailable = isHideBalances == false
) {
@ -235,7 +235,7 @@ class MainActivity : FragmentActivity() {
@Composable
private fun MainContent() {
val configuration = homeViewModel.configurationFlow.collectAsStateWithLifecycle().value
val configuration = oldHomeViewModel.configurationFlow.collectAsStateWithLifecycle().value
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
// Note this condition needs to be kept in sync with the condition in setupSplashScreen()
@ -295,7 +295,7 @@ class MainActivity : FragmentActivity() {
val isEnableBackgroundSyncFlow =
run {
val isSecretReadyFlow = walletViewModel.secretState.map { it is SecretState.Ready }
val isBackgroundSyncEnabledFlow = homeViewModel.isBackgroundSyncEnabled.filterNotNull()
val isBackgroundSyncEnabledFlow = oldHomeViewModel.isBackgroundSyncEnabled.filterNotNull()
isSecretReadyFlow.combine(isBackgroundSyncEnabledFlow) { isSecretReady, isBackgroundSyncEnabled ->
isSecretReady && isBackgroundSyncEnabled

View File

@ -4,12 +4,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType
@ -22,17 +22,12 @@ import cash.z.ecc.android.sdk.Synchronizer
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.spackle.getSerializableCompat
import co.electriccoin.zcash.ui.NavigationArgs.ADDRESS_TYPE
import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_ZIP_321_URI
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
import co.electriccoin.zcash.ui.NavigationTargets.DELETE_WALLET
import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
@ -42,16 +37,13 @@ import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.model.SerializableAddress
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.provider.isInForeground
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.enterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.exitTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popEnterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition
import co.electriccoin.zcash.ui.screen.ExternalUrl
import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.about.util.WebBrowserUtil
import co.electriccoin.zcash.ui.screen.accountlist.AccountList
import co.electriccoin.zcash.ui.screen.accountlist.AndroidAccountList
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs
@ -72,14 +64,21 @@ import co.electriccoin.zcash.ui.screen.exchangerate.optin.AndroidExchangeRateOpt
import co.electriccoin.zcash.ui.screen.exchangerate.settings.AndroidSettingsExchangeRateOptIn
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.feedback.WrapFeedback
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.integrations.WrapIntegrations
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import co.electriccoin.zcash.ui.screen.home.AndroidHome
import co.electriccoin.zcash.ui.screen.home.Home
import co.electriccoin.zcash.ui.screen.integrations.AndroidDialogIntegrations
import co.electriccoin.zcash.ui.screen.integrations.AndroidIntegrations
import co.electriccoin.zcash.ui.screen.integrations.DialogIntegrations
import co.electriccoin.zcash.ui.screen.integrations.Integrations
import co.electriccoin.zcash.ui.screen.qrcode.WrapQrCode
import co.electriccoin.zcash.ui.screen.receive.AndroidReceive
import co.electriccoin.zcash.ui.screen.receive.Receive
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest
import co.electriccoin.zcash.ui.screen.reviewtransaction.AndroidReviewTransaction
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
import co.electriccoin.zcash.ui.screen.scankeystone.ScanKeystonePCZTRequest
import co.electriccoin.zcash.ui.screen.scankeystone.ScanKeystoneSignInRequest
@ -89,7 +88,8 @@ import co.electriccoin.zcash.ui.screen.seed.SeedNavigationArgs
import co.electriccoin.zcash.ui.screen.seed.WrapSeed
import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.AndroidSelectKeystoneAccount
import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount
import co.electriccoin.zcash.ui.screen.send.model.SendArguments
import co.electriccoin.zcash.ui.screen.send.Send
import co.electriccoin.zcash.ui.screen.send.WrapSend
import co.electriccoin.zcash.ui.screen.settings.WrapSettings
import co.electriccoin.zcash.ui.screen.signkeystonetransaction.AndroidSignKeystoneTransaction
import co.electriccoin.zcash.ui.screen.signkeystonetransaction.SignKeystoneTransaction
@ -110,8 +110,8 @@ import co.electriccoin.zcash.ui.screen.whatsnew.WrapWhatsNew
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.koin.android.ext.android.inject
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
// TODO [#1297]: Consider: Navigation passing complex data arguments different way
@ -120,6 +120,8 @@ import org.koin.compose.koinInject
@Suppress("LongMethod", "CyclomaticComplexMethod")
internal fun MainActivity.Navigation() {
val navController = LocalNavController.current
val flexaViewModel = koinViewModel<FlexaViewModel>()
val navigationRouter = koinInject<NavigationRouter>()
// Helper properties for triggering the system security UI from callbacks
val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) =
@ -128,69 +130,25 @@ internal fun MainActivity.Navigation() {
rememberSaveable { mutableStateOf(false) }
val (deleteWalletAuthentication, setDeleteWalletAuthentication) =
rememberSaveable { mutableStateOf(false) }
val navigationRouter = koinInject<NavigationRouter>()
val navigator: Navigator = remember { NavigatorImpl(this@Navigation, navController, flexaViewModel) }
LaunchedEffect(Unit) {
navigationRouter.observe().collect {
when (it) {
is NavigationCommand.Forward ->
if (it.route is ExternalUrl) {
WebBrowserUtil.startActivity(this@Navigation, it.route.url)
} else {
navController.executeNavigation(route = it.route)
}
is NavigationCommand.Replace ->
if (it.route is ExternalUrl) {
navController.popBackStack()
WebBrowserUtil.startActivity(this@Navigation, it.route.url)
} else {
navController.executeNavigation(route = it.route) {
popUpTo(navController.currentBackStackEntry?.destination?.id ?: 0) {
inclusive = true
}
}
}
is NavigationCommand.ReplaceAll ->
if (it.route is ExternalUrl) {
navController.popBackStack(
route = navController.graph.startDestinationId,
inclusive = false
)
WebBrowserUtil.startActivity(this@Navigation, it.route.url)
} else {
navController.executeNavigation(route = it.route) {
popUpTo(navController.graph.startDestinationId) {
inclusive = false
}
}
}
is NavigationCommand.NewRoot ->
navController.executeNavigation(route = it.route) {
popUpTo(navController.graph.startDestinationId) {
inclusive = true
}
}
NavigationCommand.Back -> navController.popBackStack()
NavigationCommand.BackToRoot ->
navController.popBackStack(
destinationId = navController.graph.startDestinationId,
inclusive = false
)
}
navigationRouter.observePipeline().collect {
navigator.executeCommand(it)
}
}
NavHost(
navController = navController,
startDestination = HOME,
startDestination = Home,
enterTransition = { enterTransition() },
exitTransition = { exitTransition() },
popEnterTransition = { popEnterTransition() },
popExitTransition = { popExitTransition() }
) {
composable(HOME) { backStack ->
NavigationHome(navController, backStack)
composable<Home> {
NavigationHome(navController)
}
composable(SETTINGS) {
WrapSettings()
@ -287,8 +245,11 @@ internal fun MainActivity.Navigation() {
composable(WHATS_NEW) {
WrapWhatsNew()
}
composable(INTEGRATIONS) {
WrapIntegrations()
composable<Integrations> {
AndroidIntegrations()
}
dialog<DialogIntegrations> {
AndroidDialogIntegrations()
}
composable(EXCHANGE_RATE_OPT_IN) {
AndroidExchangeRateOptIn()
@ -315,18 +276,18 @@ internal fun MainActivity.Navigation() {
AndroidAccountList()
}
composable(
route = ScanNavigationArgs.ROUTE,
route = Scan.ROUTE,
arguments =
listOf(
navArgument(ScanNavigationArgs.KEY) {
type = NavType.EnumType(ScanNavigationArgs::class.java)
defaultValue = ScanNavigationArgs.DEFAULT
navArgument(Scan.KEY) {
type = NavType.EnumType(Scan::class.java)
defaultValue = Scan.SEND
}
)
) { backStackEntry ->
val mode =
backStackEntry.arguments
?.getSerializableCompat<ScanNavigationArgs>(ScanNavigationArgs.KEY) ?: ScanNavigationArgs.DEFAULT
?.getSerializableCompat<Scan>(Scan.KEY) ?: Scan.SEND
WrapScanValidator(args = mode)
}
@ -438,6 +399,12 @@ internal fun MainActivity.Navigation() {
composable<TaxExport> {
AndroidTaxExport()
}
composable<Receive> {
AndroidReceive()
}
composable<Send> {
WrapSend(it.toRoute())
}
}
}
@ -445,30 +412,10 @@ internal fun MainActivity.Navigation() {
* This is the Home screens sub-navigation. We could consider creating a separate sub-navigation graph.
*/
@Composable
private fun MainActivity.NavigationHome(
navController: NavHostController,
backStack: NavBackStackEntry
) {
private fun MainActivity.NavigationHome(navController: NavHostController) {
val applicationStateProvider: ApplicationStateProvider by inject()
WrapHome(
goScan = { navController.navigateJustOnce(ScanNavigationArgs(ScanNavigationArgs.DEFAULT)) },
sendArguments =
SendArguments(
recipientAddress =
backStack.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let {
Json.decodeFromString<SerializableAddress>(it).toRecipient()
},
zip321Uri = backStack.savedStateHandle.get<String>(SEND_SCAN_ZIP_321_URI),
clearForm = backStack.savedStateHandle.get<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM) ?: false
).also {
// Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if
// some exist after we use them
backStack.savedStateHandle.remove<String>(SEND_SCAN_RECIPIENT_ADDRESS)
backStack.savedStateHandle.remove<String>(SEND_SCAN_ZIP_321_URI)
backStack.savedStateHandle.remove<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM)
},
)
AndroidHome()
val isEnoughSpace by storageCheckViewModel.isEnoughSpace.collectAsStateWithLifecycle()
@ -560,25 +507,6 @@ fun NavHostController.navigateJustOnce(
}
}
private fun NavHostController.executeNavigation(
route: Any,
builder: (NavOptionsBuilder.() -> Unit)? = null
) {
if (route is String) {
if (builder == null) {
navigate(route)
} else {
navigate(route, builder)
}
} else {
if (builder == null) {
navigate(route)
} else {
navigate(route, builder)
}
}
}
/**
* Pops up the current screen from the back stack. Parameter currentRouteToBePopped is meant to be
* set only to the current screen so we can easily debounce multiple screen popping from the back stack.
@ -592,21 +520,13 @@ fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) {
popBackStack()
}
object NavigationArguments {
const val SEND_SCAN_RECIPIENT_ADDRESS = "send_scan_recipient_address"
const val SEND_SCAN_ZIP_321_URI = "send_scan_zip_321_uri"
const val MULTIPLE_SUBMISSION_CLEAR_FORM = "multiple_submission_clear_form"
}
object NavigationTargets {
const val ABOUT = "about"
const val ADVANCED_SETTINGS = "advanced_settings"
const val DELETE_WALLET = "delete_wallet"
const val EXCHANGE_RATE_OPT_IN = "exchange_rate_opt_in"
const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HOME = "home"
const val CHOOSE_SERVER = "choose_server"
const val INTEGRATIONS = "integrations"
const val NOT_ENOUGH_SPACE = "not_enough_space"
const val QR_CODE = "qr_code"
const val REQUEST = "request"

View File

@ -7,21 +7,37 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlin.reflect.KClass
interface NavigationRouter {
fun forward(route: Any)
/**
* Add [routes] to backstack.
*/
fun forward(vararg routes: Any)
fun replace(route: Any)
/**
* Replace current screen by the first route and add the rest of [routes] to backstack.
*/
fun replace(vararg routes: Any)
fun replaceAll(route: Any)
fun newRoot(route: Any)
/**
* Pop all screens except for the root and add [routes] to backstack.
*/
fun replaceAll(vararg routes: Any)
/**
* Pop last screen from backstack.
*/
fun back()
fun backTo(route: KClass<*>)
/**
* Pop all screens from backstack except for the root.
*/
fun backToRoot()
fun observe(): Flow<NavigationCommand>
fun observePipeline(): Flow<NavigationCommand>
}
class NavigationRouterImpl : NavigationRouter {
@ -29,27 +45,21 @@ class NavigationRouterImpl : NavigationRouter {
private val channel = Channel<NavigationCommand>()
override fun forward(route: Any) {
override fun forward(vararg routes: Any) {
scope.launch {
channel.send(NavigationCommand.Forward(route))
channel.send(NavigationCommand.Forward(routes.toList()))
}
}
override fun replace(route: Any) {
override fun replace(vararg routes: Any) {
scope.launch {
channel.send(NavigationCommand.Replace(route))
channel.send(NavigationCommand.Replace(routes.toList()))
}
}
override fun replaceAll(route: Any) {
override fun replaceAll(vararg routes: Any) {
scope.launch {
channel.send(NavigationCommand.ReplaceAll(route))
}
}
override fun newRoot(route: Any) {
scope.launch {
channel.send(NavigationCommand.NewRoot(route))
channel.send(NavigationCommand.ReplaceAll(routes.toList()))
}
}
@ -59,25 +69,31 @@ class NavigationRouterImpl : NavigationRouter {
}
}
override fun backTo(route: KClass<*>) {
scope.launch {
channel.send(NavigationCommand.BackTo(route))
}
}
override fun backToRoot() {
scope.launch {
channel.send(NavigationCommand.BackToRoot)
}
}
override fun observe() = channel.receiveAsFlow()
override fun observePipeline() = channel.receiveAsFlow()
}
sealed interface NavigationCommand {
data class Forward(val route: Any) : NavigationCommand
data class Forward(val routes: List<Any>) : NavigationCommand
data class Replace(val route: Any) : NavigationCommand
data class Replace(val routes: List<Any>) : NavigationCommand
data class ReplaceAll(val route: Any) : NavigationCommand
data class NewRoot(val route: Any) : NavigationCommand
data class ReplaceAll(val routes: List<Any>) : NavigationCommand
data object Back : NavigationCommand
data class BackTo(val route: KClass<*>) : NavigationCommand
data object BackToRoot : NavigationCommand
}

View File

@ -0,0 +1,175 @@
package co.electriccoin.zcash.ui
import android.annotation.SuppressLint
import androidx.activity.ComponentActivity
import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.serialization.generateHashCode
import co.electriccoin.zcash.ui.screen.ExternalUrl
import co.electriccoin.zcash.ui.screen.about.util.WebBrowserUtil
import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import com.flexa.core.Flexa
import com.flexa.spend.buildSpend
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
interface Navigator {
fun executeCommand(command: NavigationCommand)
}
class NavigatorImpl(
private val activity: ComponentActivity,
private val navController: NavHostController,
private val flexaViewModel: FlexaViewModel,
) : Navigator {
override fun executeCommand(command: NavigationCommand) {
when (command) {
is NavigationCommand.Forward -> forward(command)
is NavigationCommand.Replace -> replace(command)
is NavigationCommand.ReplaceAll -> replaceAll(command)
NavigationCommand.Back -> navController.popBackStack()
is NavigationCommand.BackTo -> backTo(command)
NavigationCommand.BackToRoot -> backToRoot()
}
}
@SuppressLint("RestrictedApi")
@OptIn(InternalSerializationApi::class)
private fun backTo(command: NavigationCommand.BackTo) {
navController.popBackStack(
destinationId = command.route.serializer().generateHashCode(),
inclusive = false
)
}
private fun backToRoot() {
navController.popBackStack(
destinationId = navController.graph.startDestinationId,
inclusive = false
)
}
private fun replaceAll(command: NavigationCommand.ReplaceAll) {
command.routes.forEachIndexed { index, route ->
when (route) {
co.electriccoin.zcash.ui.screen.flexa.Flexa -> {
if (index == 0) {
navController.popBackStack(
route = navController.graph.startDestinationId,
inclusive = false
)
}
if (index != command.routes.lastIndex) {
throw UnsupportedOperationException("Flexa can be opened as last screen only")
}
createFlexaFlow(flexaViewModel)
}
is ExternalUrl -> {
if (index == 0) {
navController.popBackStack(
route = navController.graph.startDestinationId,
inclusive = false
)
}
if (index != command.routes.lastIndex) {
throw UnsupportedOperationException("External url can be opened as last screen only")
}
WebBrowserUtil.startActivity(activity, route.url)
}
else -> {
navController.executeNavigation(route = route) {
if (index == 0) {
popUpTo(navController.graph.startDestinationId) {
inclusive = false
}
}
}
}
}
}
}
private fun replace(command: NavigationCommand.Replace) {
command.routes.forEachIndexed { index, route ->
when (route) {
co.electriccoin.zcash.ui.screen.flexa.Flexa -> {
if (index == 0) {
navController.popBackStack()
}
if (index != command.routes.lastIndex) {
throw UnsupportedOperationException("Flexa can be opened as last screen only")
}
createFlexaFlow(flexaViewModel)
}
is ExternalUrl -> {
if (index == 0) {
navController.popBackStack()
}
if (index != command.routes.lastIndex) {
throw UnsupportedOperationException("External url can be opened as last screen only")
}
WebBrowserUtil.startActivity(activity, route.url)
}
else -> {
navController.executeNavigation(route = route) {
if (index == 0) {
popUpTo(navController.currentBackStackEntry?.destination?.id ?: 0) {
inclusive = true
}
}
}
}
}
}
}
private fun forward(command: NavigationCommand.Forward) {
command.routes.forEach { route ->
when (route) {
co.electriccoin.zcash.ui.screen.flexa.Flexa -> createFlexaFlow(flexaViewModel)
is ExternalUrl -> WebBrowserUtil.startActivity(activity, route.url)
else -> navController.executeNavigation(route = route)
}
}
}
private fun NavHostController.executeNavigation(
route: Any,
builder: (NavOptionsBuilder.() -> Unit)? = null
) {
if (route is String) {
if (builder == null) {
navigate(route)
} else {
navigate(route, builder)
}
} else {
if (builder == null) {
navigate(route)
} else {
navigate(route, builder)
}
}
}
private fun createFlexaFlow(flexaViewModel: FlexaViewModel) {
Flexa.buildSpend()
.onTransactionRequest { result ->
flexaViewModel.createTransaction(result)
}
.build()
.open(activity)
}
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.common.viewmodel
package co.electriccoin.zcash.ui.common.appbar
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -11,12 +11,10 @@ import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.usecase.GetWalletStateInformationUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.AccountSwitchState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.accountlist.AccountList
@ -31,9 +29,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ZashiMainTopAppBarViewModel(
class ZashiTopAppBarViewModel(
observeSelectedWalletAccount: ObserveSelectedWalletAccountUseCase,
observeWalletState: ObserveWalletStateUseCase,
getWalletStateInformation: GetWalletStateInformationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider,
private val navigationRouter: NavigationRouter,
) : ViewModel() {
@ -43,7 +41,7 @@ class ZashiMainTopAppBarViewModel(
combine(
observeSelectedWalletAccount.require(),
isHideBalances,
observeWalletState()
getWalletStateInformation.observe()
) { currentAccount, isHideBalances, walletState ->
createState(currentAccount, isHideBalances, walletState)
}.stateIn(

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.design.component
package co.electriccoin.zcash.ui.common.appbar
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
@ -24,8 +24,11 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState.AccountType
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState.AccountType
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.ZashiIconButton
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
@ -35,7 +38,7 @@ import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.stringRes
@Composable
fun ZashiMainTopAppBar(
fun ZashiTopAppBarWithAccountSelection(
state: ZashiMainTopAppBarState?,
showHideBalances: Boolean = true
) {
@ -144,7 +147,7 @@ data class AccountSwitchState(
@Composable
private fun ZashiMainTopAppBarPreview() =
ZcashTheme {
ZashiMainTopAppBar(
ZashiTopAppBarWithAccountSelection(
state =
ZashiMainTopAppBarState(
accountSwitchState =
@ -163,7 +166,7 @@ private fun ZashiMainTopAppBarPreview() =
@Composable
private fun KeystoneMainTopAppBarPreview() =
ZcashTheme {
ZashiMainTopAppBar(
ZashiTopAppBarWithAccountSelection(
state =
ZashiMainTopAppBarState(
accountSwitchState =
@ -182,7 +185,7 @@ private fun KeystoneMainTopAppBarPreview() =
@Composable
private fun MainTopAppBarWithSubtitlePreview() =
ZcashTheme {
ZashiMainTopAppBar(
ZashiTopAppBarWithAccountSelection(
state =
ZashiMainTopAppBarState(
accountSwitchState =

View File

@ -0,0 +1,46 @@
package co.electriccoin.zcash.ui.common.appbar
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.component.ZashiIconButton
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.orDark
@Composable
fun ZashiTopAppbar(
state: ZashiMainTopAppBarState?,
title: StringResource? = null,
showHideBalances: Boolean = true,
onBack: () -> Unit,
) {
ZashiSmallTopAppBar(
title = title?.getValue(),
subtitle = state?.subtitle?.getValue(),
navigationAction = {
ZashiTopAppBarBackNavigation(onBack = onBack)
},
regularActions = {
if (state?.balanceVisibilityButton != null && showHideBalances) {
Crossfade(state.balanceVisibilityButton, label = "") {
ZashiIconButton(it, modifier = Modifier.size(40.dp))
}
}
Spacer(Modifier.width(20.dp))
},
colors =
ZcashTheme.colors.topAppBarColors orDark
ZcashTheme.colors.topAppBarColors.copyColors(
containerColor = Color.Transparent
),
)
}

View File

@ -1,259 +0,0 @@
package co.electriccoin.zcash.ui.common.compose
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.extension.toZecStringFull
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.LottieProgress
import co.electriccoin.zcash.ui.design.component.Reference
import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults
import co.electriccoin.zcash.ui.design.component.ZecAmountTriple
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.ObserveFiatCurrencyResultFixture
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeBalance
@Preview(device = Devices.PIXEL_2)
@Composable
private fun BalanceWidgetPreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface(
modifier = Modifier.fillMaxWidth()
) {
@Suppress("MagicNumber")
(
BalanceWidget(
balanceState =
BalanceState.Available(
totalBalance = Zatoshi(1234567891234567L),
spendableBalance = Zatoshi(1234567891234567L),
totalShieldedBalance = Zatoshi(0L),
exchangeRate = ObserveFiatCurrencyResultFixture.new()
),
isHideBalances = false,
isReferenceToBalances = true,
onReferenceClick = {},
modifier = Modifier,
)
)
}
}
}
@Preview(device = Devices.PIXEL_2)
@Composable
private fun BalanceWidgetNotAvailableYetPreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface(
modifier = Modifier.fillMaxWidth()
) {
@Suppress("MagicNumber")
BalanceWidget(
balanceState =
BalanceState.Loading(
totalBalance = Zatoshi(value = 0L),
spendableBalance = Zatoshi(value = 0L),
totalShieldedBalance = Zatoshi(0L),
exchangeRate = ObserveFiatCurrencyResultFixture.new()
),
isHideBalances = false,
isReferenceToBalances = true,
onReferenceClick = {},
modifier = Modifier,
)
}
}
}
@Preview
@Composable
private fun BalanceWidgetHiddenAmountPreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface(
modifier = Modifier.fillMaxWidth()
) {
@Suppress("MagicNumber")
BalanceWidget(
balanceState =
BalanceState.Loading(
totalBalance = Zatoshi(0L),
spendableBalance = Zatoshi(0L),
totalShieldedBalance = Zatoshi(0L),
exchangeRate = ObserveFiatCurrencyResultFixture.new()
),
isHideBalances = true,
isReferenceToBalances = true,
onReferenceClick = {},
modifier = Modifier,
)
}
}
}
sealed interface BalanceState {
val totalBalance: Zatoshi
val totalShieldedBalance: Zatoshi
val spendableBalance: Zatoshi
val exchangeRate: ExchangeRateState
data class None(
override val exchangeRate: ExchangeRateState
) : BalanceState {
override val totalBalance: Zatoshi = Zatoshi(0L)
override val totalShieldedBalance: Zatoshi = Zatoshi(0L)
override val spendableBalance: Zatoshi = Zatoshi(0L)
}
data class Loading(
override val totalBalance: Zatoshi,
override val spendableBalance: Zatoshi,
override val exchangeRate: ExchangeRateState,
override val totalShieldedBalance: Zatoshi
) : BalanceState
data class Available(
override val totalBalance: Zatoshi,
override val spendableBalance: Zatoshi,
override val exchangeRate: ExchangeRateState,
override val totalShieldedBalance: Zatoshi
) : BalanceState
}
@Composable
@Suppress("LongMethod")
fun BalanceWidget(
balanceState: BalanceState,
isReferenceToBalances: Boolean,
isHideBalances: Boolean,
onReferenceClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier =
Modifier
.wrapContentSize()
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
BalanceWidgetBigLineOnly(
isHideBalances = isHideBalances,
parts = balanceState.totalBalance.toZecStringFull().asZecAmountTriple()
)
if (balanceState.exchangeRate is ExchangeRateState.Data) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingMid))
}
StyledExchangeBalance(
zatoshi = balanceState.totalBalance,
state = balanceState.exchangeRate,
isHideBalances = isHideBalances
)
if (balanceState.exchangeRate is ExchangeRateState.Data) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (isReferenceToBalances) {
Reference(
text = stringResource(id = co.electriccoin.zcash.ui.R.string.balance_widget_available),
onClick = onReferenceClick,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.padding(
vertical = ZcashTheme.dimens.spacingSmall,
horizontal = ZcashTheme.dimens.spacingMini,
)
)
} else {
Body(
text = stringResource(id = co.electriccoin.zcash.ui.R.string.balance_widget_available),
modifier =
Modifier
.padding(
vertical = ZcashTheme.dimens.spacingSmall,
horizontal = ZcashTheme.dimens.spacingMini,
)
)
}
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
when (balanceState) {
is BalanceState.None, is BalanceState.Loading -> LottieProgress()
is BalanceState.Available -> {
StyledBalance(
balanceParts = balanceState.spendableBalance.toZecStringFull().asZecAmountTriple(),
isHideBalances = isHideBalances,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.balanceWidgetStyles.third,
leastSignificantPart = ZcashTheme.extendedTypography.balanceWidgetStyles.fourth
)
)
}
}
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingMin))
Body(
text = ZcashCurrency.getLocalizedName(LocalContext.current),
textFontWeight = FontWeight.Bold
)
}
}
}
@Composable
fun BalanceWidgetBigLineOnly(
parts: ZecAmountTriple,
isHideBalances: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
StyledBalance(
balanceParts = parts,
isHideBalances = isHideBalances,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.balanceWidgetStyles.first,
leastSignificantPart = ZcashTheme.extendedTypography.balanceWidgetStyles.second
)
)
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingSmall))
Image(
painter = painterResource(id = R.drawable.ic_zcash_zec_icon),
contentDescription = null,
)
}
}

View File

@ -1,155 +0,0 @@
package co.electriccoin.zcash.ui.common.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.PercentDecimal.Companion.ONE_HUNDRED_PERCENT
import cash.z.ecc.sdk.extension.toPercentageWithDecimal
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.BodySmall
import co.electriccoin.zcash.ui.design.component.SmallLinearProgressIndicator
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.balances.model.WalletDisplayValues
import co.electriccoin.zcash.ui.screen.balances.model.isReportable
@Preview(device = Devices.PIXEL_4_XL)
@Composable
private fun BalanceWidgetPreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface(
modifier = Modifier.fillMaxWidth()
) {
SynchronizationStatus(
onStatusClick = {},
walletSnapshot = WalletSnapshotFixture.new(),
)
}
}
}
@Composable
fun SynchronizationStatus(
onStatusClick: (StatusAction) -> Unit,
walletSnapshot: WalletSnapshot,
modifier: Modifier = Modifier,
testTag: String? = null,
) {
val walletDisplayValues =
WalletDisplayValues.getNextValues(
context = LocalContext.current,
walletSnapshot = walletSnapshot,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
if (walletDisplayValues.statusText.isNotEmpty()) {
BodySmall(
text = walletDisplayValues.statusText,
textAlign = TextAlign.Center,
modifier =
Modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onStatusClick(walletDisplayValues.statusAction) }
.padding(all = ZcashTheme.dimens.spacingSmall)
.then(testTag?.let { Modifier.testTag(testTag) } ?: Modifier),
)
}
BodySmall(
text =
stringResource(
id = R.string.balances_status_syncing_percentage,
walletSnapshot.progress.toCheckedProgress(walletSnapshot.status)
),
textFontWeight = FontWeight.Black
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
SmallLinearProgressIndicator(
progress = walletSnapshot.progress.decimal,
modifier =
Modifier.padding(
horizontal = ZcashTheme.dimens.spacingDefault
)
)
}
}
private const val UNCOMPLETED_PERCENTAGE = 0.9999f
/**
* This function ensures that a non finished percentage is returned in case `Synchronizer.Status` is still in the
* `SYNCING` state.
*
* @return String with value 99.99 if the `Synchronizer` is still running, another expected value otherwise.
*/
private fun PercentDecimal.toCheckedProgress(status: Synchronizer.Status): String =
if (status == Synchronizer.Status.SYNCING && this == ONE_HUNDRED_PERCENT) {
PercentDecimal(UNCOMPLETED_PERCENTAGE).toPercentageWithDecimal()
} else {
toPercentageWithDecimal()
}
@Composable
fun StatusDialog(
statusAction: StatusAction.Detailed,
onDone: () -> Unit,
onReport: (StatusAction.Error) -> Unit,
) {
AppAlertDialog(
title = stringResource(id = R.string.balances_status_error_dialog_title),
text = {
Column(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
text = statusAction.details,
color = ZcashTheme.colors.textPrimary,
)
}
},
confirmButtonText = stringResource(id = R.string.balances_status_dialog_ok_button),
onConfirmButtonClick = onDone,
// Add the report button only for the StatusAction.Error type and non-null full stacktrace value
dismissButtonText =
if (statusAction.isReportable()) {
stringResource(id = R.string.balances_status_dialog_report_button)
} else {
null
},
onDismissButtonClick =
if (statusAction.isReportable()) {
{ onReport(statusAction as StatusAction.Error) }
} else {
null
},
)
}

View File

@ -17,7 +17,6 @@ import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.model.SubmitResult
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
import co.electriccoin.zcash.ui.screen.balances.DEFAULT_SHIELDING_THRESHOLD
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
@ -92,19 +91,21 @@ class ProposalDataSourceImpl(
zip321Uri: String
): Zip321TransactionProposal =
withContext(Dispatchers.IO) {
val synchronizer = synchronizerProvider.getSynchronizer()
val request =
getOrThrow {
ZIP321.request(uriString = zip321Uri, validatingRecipients = null)
}
val payment =
when (request) {
is ZIP321.ParserResult.Request -> request.paymentRequest.payments[0]
else -> throw TransactionProposalNotCreatedException(IllegalArgumentException("Invalid ZIP321 URI"))
}
getOrThrow {
val synchronizer = synchronizerProvider.getSynchronizer()
val request =
getOrThrow {
ZIP321.request(uriString = zip321Uri, validatingRecipients = null)
}
val payment =
when (request) {
is ZIP321.ParserResult.Request -> request.paymentRequest.payments[0]
else -> throw TransactionProposalNotCreatedException(
IllegalArgumentException("Invalid ZIP321 URI"),
)
}
Zip321TransactionProposal(
destination =
synchronizer
@ -276,3 +277,5 @@ data class Zip321TransactionProposal(
override val memo: Memo,
override val proposal: Proposal
) : SendTransactionProposal
private const val DEFAULT_SHIELDING_THRESHOLD = 100000L

View File

@ -18,22 +18,7 @@ data class WalletSnapshot(
val transparentBalance: Zatoshi,
val progress: PercentDecimal,
val synchronizerError: SynchronizerError?
) {
// Note: the wallet's transparent balance is effectively empty if it cannot cover the miner's fee
val hasTransparentFunds = transparentBalance.value > 0L
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasSaplingFunds = (saplingBalance?.available?.value ?: 0) > 0L
val hasSaplingBalance = (saplingBalance?.total?.value ?: 0) > 0L
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasOrchardFunds = orchardBalance.available.value > 0L
val hasOrchardBalance = orchardBalance.total.value > 0L
val isSendEnabled: Boolean get() = hasSaplingFunds && hasOrchardFunds
}
)
// TODO [#1370]: WalletSnapshot.canSpend() calculation limitation
// TODO [#1370]: https://github.com/Electric-Coin-Company/zashi-android/issues/1370
@ -43,19 +28,7 @@ fun WalletSnapshot.canSpend(amount: Zatoshi): Boolean = spendableBalance() >= am
fun WalletSnapshot.totalBalance() = orchardBalance.total + (saplingBalance?.total ?: Zatoshi(0)) + transparentBalance
fun WalletSnapshot.totalShieldedBalance() = orchardBalance.total + (saplingBalance?.total ?: Zatoshi(0))
// Note that considering both to be spendable is subject to change.
// The user experience could be confusing, and in the future we might prefer to ask users
// to transfer their balance to the latest balance type to make it spendable.
fun WalletSnapshot.spendableBalance() = orchardBalance.available + (saplingBalance?.available ?: Zatoshi(0))
// Note that summing both values could be confusing, and we might prefer dividing them in the future
fun WalletSnapshot.changePendingBalance() = orchardBalance.changePending + (saplingBalance?.changePending ?: Zatoshi(0))
fun WalletSnapshot.hasChangePending() = changePendingBalance().value > 0L
// Note that summing both values could be confusing, and we might prefer dividing them in the future
fun WalletSnapshot.valuePendingBalance() = orchardBalance.valuePending + (saplingBalance?.valuePending ?: Zatoshi(0))
fun WalletSnapshot.hasValuePending() = valuePendingBalance().value > 0L

View File

@ -1,72 +0,0 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
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.totalBalance
import co.electriccoin.zcash.ui.common.model.totalShieldedBalance
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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
interface BalanceRepository {
/**
* A flow of the wallet balances state used for the UI layer. It's computed from [WalletSnapshot]'s properties
* and provides the result [BalanceState] UI state.
*/
val state: StateFlow<BalanceState>
}
class BalanceRepositoryImpl(
walletRepository: WalletRepository,
exchangeRateRepository: ExchangeRateRepository,
) : BalanceRepository {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override val state: StateFlow<BalanceState> =
combine(
walletRepository.currentWalletSnapshot.filterNotNull(),
exchangeRateRepository.state,
) { snapshot, exchangeRateUsd ->
when {
// Show the loader only under these conditions:
// - Available balance is currently zero AND total balance is non-zero
// - And wallet has some ChangePending or ValuePending in progress
(
snapshot.spendableBalance().value == 0L &&
snapshot.totalBalance().value > 0L &&
(snapshot.hasChangePending() || snapshot.hasValuePending())
) -> {
BalanceState.Loading(
totalBalance = snapshot.totalBalance(),
spendableBalance = snapshot.spendableBalance(),
exchangeRate = exchangeRateUsd,
totalShieldedBalance = snapshot.totalShieldedBalance()
)
}
else -> {
BalanceState.Available(
totalBalance = snapshot.totalBalance(),
spendableBalance = snapshot.spendableBalance(),
exchangeRate = exchangeRateUsd,
totalShieldedBalance = snapshot.totalShieldedBalance()
)
}
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
BalanceState.None(ExchangeRateState.OptedOut)
)
}

View File

@ -3,10 +3,12 @@ package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.exception.PcztException
import cash.z.ecc.android.sdk.model.Pczt
import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.ProposalDataSource
import co.electriccoin.zcash.ui.common.datasource.TransactionProposal
import co.electriccoin.zcash.ui.common.datasource.TransactionProposalNotCreatedException
import co.electriccoin.zcash.ui.common.datasource.Zip321TransactionProposal
import co.electriccoin.zcash.ui.common.model.SubmitResult
import com.keystone.sdk.KeystoneSDK
import com.sparrowwallet.hummingbird.UR
@ -33,7 +35,7 @@ interface KeystoneProposalRepository {
suspend fun createProposal(zecSend: ZecSend)
@Throws(TransactionProposalNotCreatedException::class)
suspend fun createZip321Proposal(zip321Uri: String)
suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal
@Throws(TransactionProposalNotCreatedException::class)
suspend fun createShieldProposal()
@ -94,8 +96,8 @@ class KeystoneProposalRepositoryImpl(
}
}
override suspend fun createZip321Proposal(zip321Uri: String) {
createProposalInternal {
override suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal {
return createProposalInternal {
proposalDataSource.createZip321Proposal(
account = accountDataSource.getSelectedAccount(),
zip321Uri = zip321Uri
@ -213,15 +215,17 @@ class KeystoneProposalRepositoryImpl(
pcztWithSignatures = null
}
private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T) {
private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T): T {
val proposal =
try {
block()
} catch (e: TransactionProposalNotCreatedException) {
Twig.error(e) { "Unable to create proposal" }
transactionProposal.update { null }
throw e
}
transactionProposal.update { proposal }
return proposal
}
}

View File

@ -1,11 +1,13 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.ZecSend
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.ProposalDataSource
import co.electriccoin.zcash.ui.common.datasource.TransactionProposal
import co.electriccoin.zcash.ui.common.datasource.TransactionProposalNotCreatedException
import co.electriccoin.zcash.ui.common.datasource.ZashiSpendingKeyDataSource
import co.electriccoin.zcash.ui.common.datasource.Zip321TransactionProposal
import co.electriccoin.zcash.ui.common.model.SubmitResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -27,7 +29,7 @@ interface ZashiProposalRepository {
suspend fun createProposal(zecSend: ZecSend)
@Throws(TransactionProposalNotCreatedException::class)
suspend fun createZip321Proposal(zip321Uri: String)
suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal
@Throws(TransactionProposalNotCreatedException::class)
suspend fun createShieldProposal()
@ -63,8 +65,8 @@ class ZashiProposalRepositoryImpl(
}
}
override suspend fun createZip321Proposal(zip321Uri: String) {
createProposalInternal {
override suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal {
return createProposalInternal {
proposalDataSource.createZip321Proposal(
account = accountDataSource.getSelectedAccount(),
zip321Uri = zip321Uri
@ -127,14 +129,16 @@ class ZashiProposalRepositoryImpl(
submitState.update { null }
}
private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T) {
private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T): T {
val proposal =
try {
block()
} catch (e: TransactionProposalNotCreatedException) {
Twig.error(e) { "Unable to create proposal" }
transactionProposal.update { null }
throw e
}
transactionProposal.update { proposal }
return proposal
}
}

View File

@ -3,6 +3,7 @@ package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.ZashiProposalRepository
import co.electriccoin.zcash.ui.screen.send.Send
class CancelProposalFlowUseCase(
private val keystoneProposalRepository: KeystoneProposalRepository,
@ -16,6 +17,6 @@ class CancelProposalFlowUseCase(
if (clearSendForm) {
observeClearSend.requestClear()
}
navigationRouter.backToRoot()
navigationRouter.backTo(Send::class)
}
}

View File

@ -0,0 +1,197 @@
package co.electriccoin.zcash.ui.common.usecase
import android.content.Context
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt
import cash.z.ecc.android.sdk.model.proposeSend
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.SubmitResult
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricRequest
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import com.flexa.core.Flexa
import com.flexa.spend.Transaction
import com.flexa.spend.buildSpend
class CreateFlexaTransactionUseCase(
private val getSynchronizer: GetSynchronizerUseCase,
private val getZashiAccount: GetZashiAccountUseCase,
private val getSpendingKey: GetZashiSpendingKeyUseCase,
private val biometricRepository: BiometricRepository,
private val context: Context,
) {
suspend operator fun invoke(transaction: Result<Transaction>) =
runCatching {
biometricRepository.requestBiometrics(
BiometricRequest(message = stringRes(R.string.integrations_flexa_biometric_message))
)
Twig.debug { "Getting send transaction proposal" }
getSynchronizer()
.proposeSend(
account = getZashiAccount().sdkAccount,
send = getZecSend(transaction.getOrNull())
)
}.onSuccess { proposal ->
Twig.debug { "Transaction proposal successful: ${proposal.toPrettyString()}" }
val result = submitTransactions(proposal = proposal, spendingKey = getSpendingKey())
when (val output = result.first) {
is SubmitResult.Success -> {
Twig.debug { "Transaction successful $result" }
Flexa.buildSpend()
.transactionSent(
commerceSessionId = transaction.getOrNull()?.commerceSessionId.orEmpty(),
txSignature = result.second.orEmpty()
)
}
is SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc -> {
Twig.warn { "Transaction grpc failure $result" }
Flexa.buildSpend()
.transactionSent(
commerceSessionId = transaction.getOrNull()?.commerceSessionId.orEmpty(),
txSignature = output.result.txIdString()
)
}
else -> {
Twig.error { "Transaction submission failed" }
}
}
}.onFailure {
Twig.error(it) { "Transaction proposal failed" }
}
private suspend fun submitTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
): Pair<SubmitResult, String?> {
Twig.debug { "Sending transactions..." }
val result =
runCreateTransactions(
synchronizer = getSynchronizer(),
spendingKey = spendingKey,
proposal = proposal
)
// Triggering the transaction history and balances refresh to be notified immediately
// about the wallet's updated state
(getSynchronizer() as SdkSynchronizer).run {
refreshTransactions()
refreshAllBalances()
}
return result
}
private suspend fun runCreateTransactions(
synchronizer: Synchronizer,
spendingKey: UnifiedSpendingKey,
proposal: Proposal
): Pair<SubmitResult, String?> {
val submitResults = mutableListOf<TransactionSubmitResult>()
return runCatching {
synchronizer.createProposedTransactions(
proposal = proposal,
usk = spendingKey
).collect { submitResult ->
Twig.info { "Transaction submit result: $submitResult" }
submitResults.add(submitResult)
}
if (submitResults.find { it is TransactionSubmitResult.Failure } != null) {
if (submitResults.size == 1) {
// The first transaction submission failed - user might just be able to re-submit the transaction
// proposal. Simple error pop up is fine then
val result = (submitResults[0] as TransactionSubmitResult.Failure)
if (result.grpcError) {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc(result) to null
} else {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureSubmit(result) to null
}
} else {
// Any subsequent transaction submission failed - user needs to resolve this manually. Multiple
// transaction failure screen presented
SubmitResult.MultipleTrxFailure(submitResults) to null
}
} else {
// All transaction submissions were successful
SubmitResult.Success(emptyList()) to
submitResults.filterIsInstance<TransactionSubmitResult.Success>()
.map { it.txIdString() }.firstOrNull()
}
}.onSuccess {
Twig.debug { "Transactions submitted successfully" }
}.onFailure {
Twig.error(it) { "Transactions submission failed" }
}.getOrElse {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureOther(it) to null
}
}
@Suppress("TooGenericExceptionThrown")
private suspend fun getZecSend(transaction: Transaction?): ZecSend {
if (transaction == null) throw NullPointerException("Transaction is null")
val address = transaction.destinationAddress.split(":").last()
val recipientAddressState =
RecipientAddressState.new(
address = address,
// TODO [#342]: Verify Addresses without Synchronizer
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
type = getSynchronizer().validateAddress(address)
)
return when (
val zecSendValidation =
ZecSendExt.new(
context = context,
destinationString = address,
zecString = transaction.amount,
// Take memo for a valid non-transparent receiver only
memoString = ""
)
) {
is ZecSendExt.ZecSendValidation.Valid ->
zecSendValidation.zecSend.copy(
destination =
when (recipientAddressState.type) {
is AddressType.Invalid ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Shielded ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Tex ->
WalletAddress.Tex.new(recipientAddressState.address)
AddressType.Transparent ->
WalletAddress.Transparent.new(recipientAddressState.address)
AddressType.Unified ->
WalletAddress.Unified.new(recipientAddressState.address)
null -> WalletAddress.Unified.new(recipientAddressState.address)
}
)
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}" }
throw RuntimeException("Validation failed")
}
}
}
}

View File

@ -1,17 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase
import cash.z.ecc.android.sdk.exception.InitializeException
import co.electriccoin.zcash.ui.HomeTabNavigationRouter
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
import com.keystone.module.ZcashAccount
import com.keystone.module.ZcashAccounts
class CreateKeystoneAccountUseCase(
private val accountDataSource: AccountDataSource,
private val navigationRouter: NavigationRouter,
private val homeTabNavigationRouter: HomeTabNavigationRouter
) {
@Throws(InitializeException.ImportAccountException::class)
suspend operator fun invoke(
@ -25,7 +22,6 @@ class CreateKeystoneAccountUseCase(
index = account.index.toLong()
)
accountDataSource.selectAccount(createdAccount)
homeTabNavigationRouter.select(HomeScreenIndex.ACCOUNT)
navigationRouter.backToRoot()
}
}

View File

@ -1,35 +0,0 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.ZashiProposalRepository
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction
class CreateZip321ProposalUseCase(
private val keystoneProposalRepository: KeystoneProposalRepository,
private val zashiProposalRepository: ZashiProposalRepository,
private val accountDataSource: AccountDataSource,
private val navigationRouter: NavigationRouter
) {
@Suppress("TooGenericExceptionCaught")
suspend operator fun invoke(zip321Uri: String) {
try {
when (accountDataSource.getSelectedAccount()) {
is KeystoneAccount -> {
keystoneProposalRepository.createZip321Proposal(zip321Uri)
keystoneProposalRepository.createPCZTFromProposal()
}
is ZashiAccount -> {
zashiProposalRepository.createZip321Proposal(zip321Uri)
}
}
navigationRouter.forward(ReviewTransaction)
} catch (e: Exception) {
keystoneProposalRepository.clear()
throw e
}
}
}

View File

@ -4,4 +4,6 @@ import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
class GetSelectedWalletAccountUseCase(private val accountDataSource: AccountDataSource) {
suspend operator fun invoke() = accountDataSource.getSelectedAccount()
fun observe() = accountDataSource.selectedAccount
}

View File

@ -2,8 +2,8 @@ package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.WalletRepository
class ObserveWalletStateUseCase(
class GetWalletStateInformationUseCase(
private val walletRepository: WalletRepository
) {
operator fun invoke() = walletRepository.walletStateInformation
fun observe() = walletRepository.walletStateInformation
}

View File

@ -0,0 +1,46 @@
package co.electriccoin.zcash.ui.common.usecase
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.send.Send
class OnAddressScannedUseCase(
private val navigationRouter: NavigationRouter,
private val prefillSend: PrefillSendUseCase
) {
operator fun invoke(
address: String,
addressType: AddressType,
scanFlow: Scan
) {
require(addressType is AddressType.Valid)
when (scanFlow) {
Scan.SEND -> {
prefillSend.request(PrefillSendData.FromAddressScan(address = address))
navigationRouter.back()
}
Scan.ADDRESS_BOOK -> {
navigationRouter.replace(AddContactArgs(address))
}
Scan.HOMEPAGE -> {
navigationRouter.replace(
Send(
address,
when (addressType) {
AddressType.Shielded -> cash.z.ecc.sdk.model.AddressType.UNIFIED
AddressType.Tex -> cash.z.ecc.sdk.model.AddressType.TEX
AddressType.Transparent -> cash.z.ecc.sdk.model.AddressType.TRANSPARENT
AddressType.Unified -> cash.z.ecc.sdk.model.AddressType.UNIFIED
is AddressType.Invalid -> cash.z.ecc.sdk.model.AddressType.UNIFIED
}
)
)
}
}
}
}

View File

@ -0,0 +1,91 @@
package co.electriccoin.zcash.ui.common.usecase
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.sdk.model.AddressType.SAPLING
import cash.z.ecc.sdk.model.AddressType.TEX
import cash.z.ecc.sdk.model.AddressType.TRANSPARENT
import cash.z.ecc.sdk.model.AddressType.UNIFIED
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.ZashiProposalRepository
import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase.Zip321ParseUriValidation
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.Scan.ADDRESS_BOOK
import co.electriccoin.zcash.ui.screen.scan.Scan.HOMEPAGE
import co.electriccoin.zcash.ui.screen.scan.Scan.SEND
import co.electriccoin.zcash.ui.screen.send.Send
class OnZip321ScannedUseCase(
private val keystoneProposalRepository: KeystoneProposalRepository,
private val zashiProposalRepository: ZashiProposalRepository,
private val accountDataSource: AccountDataSource,
private val navigationRouter: NavigationRouter,
private val prefillSend: PrefillSendUseCase
) {
suspend operator fun invoke(
zip321: Zip321ParseUriValidation.Valid,
scanFlow: Scan
) {
if (scanFlow == ADDRESS_BOOK) {
navigationRouter.replace(AddContactArgs(zip321.payment.payments[0].recipientAddress.value))
} else {
createProposal(zip321, scanFlow)
}
}
@Suppress("TooGenericExceptionCaught")
private suspend fun createProposal(
zip321: Zip321ParseUriValidation.Valid,
scanFlow: Scan
) {
try {
val proposal =
when (accountDataSource.getSelectedAccount()) {
is KeystoneAccount -> {
val result = keystoneProposalRepository.createZip321Proposal(zip321.zip321Uri)
keystoneProposalRepository.createPCZTFromProposal()
result
}
is ZashiAccount -> {
zashiProposalRepository.createZip321Proposal(zip321.zip321Uri)
}
}
if (scanFlow == HOMEPAGE) {
navigationRouter
.replace(
Send(
recipientAddress = proposal.destination.address,
recipientAddressType =
when (proposal.destination) {
is WalletAddress.Sapling -> SAPLING
is WalletAddress.Tex -> TEX
is WalletAddress.Transparent -> TRANSPARENT
is WalletAddress.Unified -> UNIFIED
}
),
ReviewTransaction
)
} else if (scanFlow == SEND) {
prefillSend.request(
PrefillSendData.All(
amount = proposal.amount,
address = proposal.destination.address,
fee = proposal.proposal.totalFeeRequired(),
memos = proposal.memo.value.takeIf { it.isNotEmpty() }?.let { listOf(it) }
)
)
navigationRouter.forward(ReviewTransaction)
}
} catch (e: Exception) {
keystoneProposalRepository.clear()
throw e
}
}
}

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.common.usecase
import cash.z.ecc.android.sdk.model.Zatoshi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -8,14 +9,37 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class PrefillSendUseCase {
private val bus = Channel<DetailedTransactionData>()
private val bus = Channel<PrefillSendData>()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
operator fun invoke() = bus.receiveAsFlow()
fun request(value: DetailedTransactionData) =
scope.launch {
bus.send(
PrefillSendData.All(
amount = value.transaction.amount,
address = value.recipientAddress?.address,
fee = value.transaction.fee,
memos = value.memos
)
)
}
fun request(value: PrefillSendData) =
scope.launch {
bus.send(value)
}
}
sealed interface PrefillSendData {
data class All(
val amount: Zatoshi,
val address: String?,
val fee: Zatoshi?,
val memos: List<String>?,
) : PrefillSendData
data class FromAddressScan(val address: String) : PrefillSendData
}

View File

@ -1,17 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.HomeTabNavigationRouter
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
import co.electriccoin.zcash.ui.screen.send.Send
class SendTransactionAgainUseCase(
private val prefillSendUseCase: PrefillSendUseCase,
private val homeTabNavigationRouter: HomeTabNavigationRouter,
private val navigationRouter: NavigationRouter
) {
operator fun invoke(value: DetailedTransactionData) {
homeTabNavigationRouter.select(HomeScreenIndex.SEND)
prefillSendUseCase.request(value)
navigationRouter.backToRoot()
navigationRouter.forward(Send())
}
}

View File

@ -1,24 +1,20 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.HomeTabNavigationRouter
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.ZashiProposalRepository
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
import co.electriccoin.zcash.ui.screen.transactiondetail.TransactionDetail
class ViewTransactionDetailAfterSuccessfulProposalUseCase(
private val keystoneProposalRepository: KeystoneProposalRepository,
private val zashiProposalRepository: ZashiProposalRepository,
private val navigationRouter: NavigationRouter,
private val homeTabNavigationRouter: HomeTabNavigationRouter,
private val observeClearSend: ObserveClearSendUseCase,
) {
operator fun invoke(txId: String) {
zashiProposalRepository.clear()
keystoneProposalRepository.clear()
observeClearSend.requestClear()
homeTabNavigationRouter.select(HomeScreenIndex.ACCOUNT)
navigationRouter.replaceAll(TransactionDetail(txId))
}
}

View File

@ -1,7 +1,6 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.ZashiProposalRepository
@ -9,12 +8,10 @@ class ViewTransactionsAfterSuccessfulProposalUseCase(
private val keystoneProposalRepository: KeystoneProposalRepository,
private val zashiProposalRepository: ZashiProposalRepository,
private val navigationRouter: NavigationRouter,
private val observeClearSend: ObserveClearSendUseCase
) {
operator fun invoke() {
zashiProposalRepository.clear()
keystoneProposalRepository.clear()
observeClearSend.requestClear()
navigationRouter.newRoot(HOME)
navigationRouter.backToRoot()
}
}

View File

@ -1,11 +1,12 @@
package co.electriccoin.zcash.ui.common.usecase
import PaymentRequest
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.spackle.Twig
import kotlinx.coroutines.runBlocking
import org.zecdev.zip321.ZIP321
internal class Zip321ParseUriValidationUseCase(
class Zip321ParseUriValidationUseCase(
private val getSynchronizerUseCase: GetSynchronizerUseCase
) {
operator fun invoke(zip321Uri: String) = validateZip321Uri(zip321Uri)
@ -24,6 +25,7 @@ internal class Zip321ParseUriValidationUseCase(
Twig.error { "Address from Zip321 validation failed: ${validation.reason}" }
false
}
else -> {
validation is AddressType.Valid
}
@ -41,15 +43,19 @@ internal class Zip321ParseUriValidationUseCase(
Twig.info { "Payment Request Zip321 validation result: $paymentRequest." }
return when (paymentRequest) {
is ZIP321.ParserResult.Request -> Zip321ParseUriValidation.Valid(zip321Uri)
is ZIP321.ParserResult.Request -> Zip321ParseUriValidation.Valid(zip321Uri, paymentRequest.paymentRequest)
is ZIP321.ParserResult.SingleAddress ->
Zip321ParseUriValidation.SingleAddress(paymentRequest.singleRecipient.value)
else -> Zip321ParseUriValidation.Invalid
}
}
internal sealed class Zip321ParseUriValidation {
data class Valid(val zip321Uri: String) : Zip321ParseUriValidation()
sealed class Zip321ParseUriValidation {
data class Valid(
val zip321Uri: String,
val payment: PaymentRequest,
) : Zip321ParseUriValidation()
data class SingleAddress(val address: String) : Zip321ParseUriValidation()

View File

@ -12,14 +12,11 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class HomeViewModel(
private val observeConfiguration: ObserveConfigurationUseCase,
class OldHomeViewModel(
observeConfiguration: ObserveConfigurationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider,
) : ViewModel() {
/**
@ -28,39 +25,13 @@ class HomeViewModel(
val isBackgroundSyncEnabled: StateFlow<Boolean?> =
booleanStateFlow(StandardPreferenceKeys.IS_BACKGROUND_SYNC_ENABLED)
/**
* A flow of whether keep screen on while syncing is on or off
*/
val isKeepScreenOnWhileSyncing: StateFlow<Boolean?> =
booleanStateFlow(StandardPreferenceKeys.IS_KEEP_SCREEN_ON_DURING_SYNC)
/**
* A flow of whether the app presented the user with restore success screem
*/
val isRestoreSuccessSeen: StateFlow<Boolean?> =
booleanStateFlow(StandardPreferenceKeys.IS_RESTORING_INITIAL_WARNING_SEEN)
fun setRestoringInitialWarningSeen() {
setBooleanPreference(StandardPreferenceKeys.IS_RESTORING_INITIAL_WARNING_SEEN, true)
}
/**
* A flow of the wallet balances visibility.
*/
val isHideBalances: StateFlow<Boolean?> = booleanStateFlow(StandardPreferenceKeys.IS_HIDE_BALANCES)
fun showOrHideBalances() {
viewModelScope.launch {
setBooleanPreference(StandardPreferenceKeys.IS_HIDE_BALANCES, isHideBalances.filterNotNull().first().not())
}
}
val configurationFlow: StateFlow<Configuration?> = observeConfiguration()
//
// PRIVATE HELPERS
//
private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> =
flow<Boolean?> {
emitAll(default.observe(standardPreferenceProvider()))
@ -69,13 +40,4 @@ class HomeViewModel(
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
private fun setBooleanPreference(
default: BooleanPreferenceDefault,
newState: Boolean
) {
viewModelScope.launch {
default.putValue(standardPreferenceProvider(), newState)
}
}
}

View File

@ -17,7 +17,6 @@ import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider
import co.electriccoin.zcash.ui.common.repository.BalanceRepository
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
@ -42,7 +41,6 @@ import kotlinx.coroutines.launch
@Suppress("LongParameterList", "TooManyFunctions")
class WalletViewModel(
application: Application,
balanceRepository: BalanceRepository,
private val walletCoordinator: WalletCoordinator,
private val walletRepository: WalletRepository,
private val exchangeRateRepository: ExchangeRateRepository,
@ -55,8 +53,6 @@ class WalletViewModel(
) : AndroidViewModel(application) {
val synchronizer = walletRepository.synchronizer
val walletRestoringState = walletRepository.walletRestoringState
val walletStateInformation = walletRepository.walletStateInformation
val secretState: StateFlow<SecretState> = walletRepository.secretState
@ -67,12 +63,6 @@ class WalletViewModel(
val exchangeRateUsd = exchangeRateRepository.state
val balanceState = balanceRepository.state
fun refreshExchangeRateUsd() {
exchangeRateRepository.refreshExchangeRateUsd()
}
fun optInExchangeRateUsd(optIn: Boolean) {
exchangeRateRepository.optInExchangeRateUsd(optIn)
}
@ -111,21 +101,6 @@ class WalletViewModel(
walletRepository.persistOnboardingState(onboardingState)
}
/**
* Asynchronously notes that the wallet has completed the initial wallet restoring block synchronization run.
*
* Note that in the current SDK implementation, we don't have any information about the block synchronization
* state from the SDK, and thus, we need to note the wallet restoring state here on the client side.
*/
fun persistWalletRestoringState(walletRestoringState: WalletRestoringState) {
viewModelScope.launch {
StandardPreferenceKeys.WALLET_RESTORING_STATE.putValue(
standardPreferenceProvider(),
walletRestoringState.toNumber()
)
}
}
fun persistExistingWalletWithSeedPhrase(
network: ZcashNetwork,
seedPhrase: SeedPhrase,

View File

@ -1,25 +1,22 @@
package co.electriccoin.zcash.ui.fixture
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.screen.balances.BalanceState
object BalanceStateFixture {
private const val BALANCE_VALUE = 0L
val TOTAL_BALANCE = Zatoshi(BALANCE_VALUE)
val TOTAL_SHIELDED_BALANCE = Zatoshi(BALANCE_VALUE)
val SPENDABLE_BALANCE = Zatoshi(BALANCE_VALUE)
fun new(
totalBalance: Zatoshi = TOTAL_BALANCE,
totalShieldedBalance: Zatoshi = TOTAL_SHIELDED_BALANCE,
spendableBalance: Zatoshi = SPENDABLE_BALANCE,
exchangeRate: ExchangeRateState = ObserveFiatCurrencyResultFixture.new()
) = BalanceState.Available(
totalBalance = totalBalance,
spendableBalance = spendableBalance,
exchangeRate = exchangeRate,
totalShieldedBalance = totalShieldedBalance
)
}

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.ui.fixture
import co.electriccoin.zcash.ui.common.appbar.AccountSwitchState
import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.AccountSwitchState
import co.electriccoin.zcash.ui.design.component.IconButtonState
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.util.stringRes
object ZashiMainTopAppBarStateFixture {

View File

@ -1,8 +0,0 @@
package co.electriccoin.zcash.ui.screen.account
/**
* These are only used for automated testing.
*/
object AccountTag {
const val BALANCE_VIEWS = "balance_views"
}

View File

@ -1,184 +0,0 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.account
import android.content.Context
import android.content.Intent
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.screen.account.view.Account
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.support.model.SupportInfo
import co.electriccoin.zcash.ui.screen.support.model.SupportInfoType
import co.electriccoin.zcash.ui.screen.support.viewmodel.SupportViewModel
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetViewModel
import co.electriccoin.zcash.ui.util.EmailUtil
import co.electriccoin.zcash.ui.util.PlayStoreUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.annotations.VisibleForTesting
import org.koin.androidx.compose.koinViewModel
@Composable
internal fun WrapAccount(goBalances: () -> Unit) {
val activity = LocalActivity.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val homeViewModel = koinActivityViewModel<HomeViewModel>()
val topAppBarViewModel = koinActivityViewModel<ZashiMainTopAppBarViewModel>()
val supportViewModel = koinActivityViewModel<SupportViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val balanceState = walletViewModel.balanceState.collectAsStateWithLifecycle().value
val walletSnapshot = walletViewModel.currentWalletSnapshot.collectAsStateWithLifecycle().value
val isHideBalances = homeViewModel.isHideBalances.collectAsStateWithLifecycle().value ?: false
val supportInfo = supportViewModel.supportInfo.collectAsStateWithLifecycle().value
val topAppBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
val walletRestoringState = walletViewModel.walletRestoringState.collectAsStateWithLifecycle().value
WrapAccount(
balanceState = balanceState,
goBalances = goBalances,
isHideBalances = isHideBalances,
supportInfo = supportInfo,
synchronizer = synchronizer,
walletSnapshot = walletSnapshot,
zashiMainTopAppBarState = topAppBarState,
walletRestoringState = walletRestoringState,
)
// For benchmarking purposes
activity.reportFullyDrawn()
}
@Composable
@VisibleForTesting
@Suppress("LongParameterList", "LongMethod")
internal fun WrapAccount(
balanceState: BalanceState,
goBalances: () -> Unit,
isHideBalances: Boolean,
synchronizer: Synchronizer?,
supportInfo: SupportInfo?,
walletSnapshot: WalletSnapshot?,
zashiMainTopAppBarState: ZashiMainTopAppBarState?,
walletRestoringState: WalletRestoringState,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val transactionHistoryWidgetViewModel = koinViewModel<TransactionHistoryWidgetViewModel>()
val transactionHistoryWidgetState by transactionHistoryWidgetViewModel.state.collectAsStateWithLifecycle()
// We could also improve this by `rememberSaveable` to preserve the dialog after a configuration change. But the
// dialog dismissing in such cases is not critical, and it would require creating StatusAction custom Saver
val showStatusDialog = remember { mutableStateOf<StatusAction.Detailed?>(null) }
if (null == synchronizer || null == walletSnapshot) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
// TODO [#1146]: https://github.com/Electric-Coin-Company/zashi-android/issues/1146
CircularScreenProgressIndicator()
} else {
Account(
balanceState = balanceState,
isHideBalances = isHideBalances,
showStatusDialog = showStatusDialog.value,
hideStatusDialog = { showStatusDialog.value = null },
onContactSupport = { status ->
val fullMessage =
EmailUtil.formatMessage(
body = status.fullStackTrace,
supportInfo = supportInfo?.toSupportString(SupportInfoType.entries.toSet())
)
val mailIntent =
EmailUtil.newMailActivityIntent(
context.getString(R.string.support_email_address),
context.getString(R.string.app_name),
fullMessage
).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
runCatching {
context.startActivity(mailIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.unable_to_open_email)
)
}
}
},
goBalances = goBalances,
snackbarHostState = snackbarHostState,
zashiMainTopAppBarState = zashiMainTopAppBarState,
transactionHistoryWidgetState = transactionHistoryWidgetState,
isWalletRestoringState = walletRestoringState,
onStatusClick = { status ->
when (status) {
is StatusAction.Detailed -> showStatusDialog.value = status
StatusAction.AppUpdate -> {
openPlayStoreAppSite(
context = context,
snackbarHostState = snackbarHostState,
scope = scope
)
}
else -> {
// No action required
}
}
},
walletSnapshot = walletSnapshot,
)
}
}
private fun openPlayStoreAppSite(
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
) {
val storeIntent = PlayStoreUtil.newActivityIntent(context)
runCatching {
context.startActivity(storeIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.unable_to_open_play_store)
)
}
}
}

View File

@ -1,11 +0,0 @@
package co.electriccoin.zcash.ui.screen.account
/**
* These are only used for automated testing.
*/
object HistoryTag {
const val TRANSACTION_LIST = "transaction_list"
const val TRANSACTION_ITEM_TITLE = "transaction_item_title"
const val TRANSACTION_ID = "transaction_id"
const val PROGRESS = "progress_bar"
}

View File

@ -1,280 +0,0 @@
package co.electriccoin.zcash.ui.screen.account.view
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.compose.BalanceWidget
import co.electriccoin.zcash.ui.common.compose.StatusDialog
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
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.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.account.AccountTag
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeOptIn
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetState
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetStateFixture
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.createTransactionHistoryWidgets
import kotlinx.datetime.Clock
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun HistoryLoadingComposablePreview() {
ZcashTheme(forceDarkMode = false) {
Account(
balanceState =
BalanceStateFixture.new(
exchangeRate = ExchangeRateState.OptIn(onDismissClick = {})
),
isHideBalances = false,
goBalances = {},
hideStatusDialog = {},
onContactSupport = {},
showStatusDialog = null,
snackbarHostState = SnackbarHostState(),
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new(),
transactionHistoryWidgetState = TransactionHistoryWidgetStateFixture.new(),
onStatusClick = {},
walletSnapshot = WalletSnapshotFixture.new(),
isWalletRestoringState = WalletRestoringState.SYNCING,
)
}
}
@Suppress("UnusedPrivateMember")
@Composable
@PreviewScreens
private fun HistoryListComposablePreview() {
ZcashTheme {
@Suppress("MagicNumber")
Account(
balanceState =
BalanceState.Available(
totalBalance = Zatoshi(value = 123_000_000L),
spendableBalance = Zatoshi(value = 123_000_000L),
totalShieldedBalance = Zatoshi(value = 123_000_000L),
exchangeRate =
ExchangeRateState.Data(
isLoading = false,
isRefreshEnabled = true,
currencyConversion =
FiatCurrencyConversion(
timestamp = Clock.System.now(),
priceOfZec = 25.0
)
) {}
),
isHideBalances = false,
goBalances = {},
hideStatusDialog = {},
onContactSupport = {},
showStatusDialog = null,
snackbarHostState = SnackbarHostState(),
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new(),
transactionHistoryWidgetState = TransactionHistoryWidgetStateFixture.new(),
onStatusClick = {},
walletSnapshot = WalletSnapshotFixture.new(),
isWalletRestoringState = WalletRestoringState.SYNCING,
)
}
}
@Composable
@Suppress("LongParameterList")
internal fun Account(
balanceState: BalanceState,
goBalances: () -> Unit,
isHideBalances: Boolean,
hideStatusDialog: () -> Unit,
onContactSupport: (StatusAction.Error) -> Unit,
showStatusDialog: StatusAction.Detailed?,
snackbarHostState: SnackbarHostState,
zashiMainTopAppBarState: ZashiMainTopAppBarState?,
transactionHistoryWidgetState: TransactionHistoryWidgetState,
isWalletRestoringState: WalletRestoringState,
onStatusClick: (StatusAction) -> Unit,
walletSnapshot: WalletSnapshot,
) {
BlankBgScaffold(
topBar = {
ZashiMainTopAppBar(zashiMainTopAppBarState)
},
snackbarHost = {
SnackbarHost(snackbarHostState)
},
) { paddingValues ->
AccountMainContent(
balanceState = balanceState,
isHideBalances = isHideBalances,
goBalances = goBalances,
modifier =
Modifier.padding(
top = paddingValues.calculateTopPadding() + ZashiDimensions.Spacing.spacingLg,
// We intentionally do not set the bottom and horizontal paddings here. Those are set by the
// underlying transaction history composable
),
paddingValues = paddingValues,
transactionHistoryWidgetState = transactionHistoryWidgetState,
isWalletRestoringState = isWalletRestoringState,
onStatusClick = onStatusClick,
walletSnapshot = walletSnapshot,
)
// Show synchronization status popup
if (showStatusDialog != null) {
StatusDialog(
statusAction = showStatusDialog,
onDone = hideStatusDialog,
onReport = { status ->
hideStatusDialog()
onContactSupport(status)
}
)
}
}
}
@Composable
@Suppress("LongParameterList", "ModifierNotUsedAtRoot", "LongMethod")
private fun AccountMainContent(
balanceState: BalanceState,
goBalances: () -> Unit,
isHideBalances: Boolean,
transactionHistoryWidgetState: TransactionHistoryWidgetState,
isWalletRestoringState: WalletRestoringState,
onStatusClick: (StatusAction) -> Unit,
walletSnapshot: WalletSnapshot,
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(),
) {
Box {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
val bottomPadding =
animateDpAsState(
targetValue =
if (balanceState.exchangeRate is ExchangeRateState.OptIn) {
112.dp
} else {
0.dp
},
animationSpec =
if (balanceState.exchangeRate is ExchangeRateState.OptIn) {
snap()
} else {
spring(visibilityThreshold = .1.dp)
},
label = "bottom padding animation"
)
BalancesStatus(
balanceState = balanceState,
goBalances = goBalances,
isHideBalances = isHideBalances,
modifier =
Modifier
.padding(
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular,
bottom = bottomPadding.value
),
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
) {
createRestoringProgressView(
onStatusClick = onStatusClick,
walletRestoringState = isWalletRestoringState,
walletSnapshot = walletSnapshot,
)
createTransactionHistoryWidgets(
state = transactionHistoryWidgetState
)
}
}
AnimatedVisibility(
visible = balanceState.exchangeRate is ExchangeRateState.OptIn,
enter = EnterTransition.None,
exit = fadeOut() + slideOutVertically(),
) {
Column {
Spacer(modifier = Modifier.height(100.dp + paddingValues.calculateTopPadding()))
StyledExchangeOptIn(
modifier = Modifier.padding(horizontal = 24.dp),
state =
(balanceState.exchangeRate as? ExchangeRateState.OptIn) ?: ExchangeRateState.OptIn(
onDismissClick = {},
)
)
}
}
}
}
@Composable
private fun BalancesStatus(
balanceState: BalanceState,
goBalances: () -> Unit,
isHideBalances: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier.then(
Modifier
.fillMaxWidth()
.testTag(AccountTag.BALANCE_VIEWS)
),
horizontalAlignment = Alignment.CenterHorizontally
) {
BalanceWidget(
balanceState = balanceState,
isHideBalances = isHideBalances,
isReferenceToBalances = true,
onReferenceClick = goBalances,
)
}
}

View File

@ -1,53 +0,0 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.account.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.balances.BalancesTag
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
internal fun LazyListScope.createRestoringProgressView(
onStatusClick: (StatusAction) -> Unit,
walletRestoringState: WalletRestoringState,
walletSnapshot: WalletSnapshot,
) {
if (walletRestoringState == WalletRestoringState.RESTORING) {
item {
Column(
modifier =
Modifier
.fillParentMaxWidth()
.background(color = ZcashTheme.colors.historySyncingColor)
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
// Do not calculate and use the app update information here, as the sync bar won't be displayed after
// the wallet is fully restored
SynchronizationStatus(
onStatusClick = onStatusClick,
testTag = BalancesTag.STATUS,
walletSnapshot = walletSnapshot,
modifier =
Modifier
.padding(horizontal = ZcashTheme.dimens.spacingDefault)
.animateContentSize()
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
Spacer(Modifier.height(8.dp))
}
}
}

View File

@ -104,6 +104,11 @@ class AccountListViewModel(
bottomSheetHiddenResponse.first()
}
private fun onBottomSheetHidden() =
viewModelScope.launch {
bottomSheetHiddenResponse.emit(Unit)
}
private fun onAccountClicked(account: WalletAccount) =
viewModelScope.launch {
selectWalletAccount(account) { hideBottomSheet() }
@ -115,11 +120,6 @@ class AccountListViewModel(
navigationRouter.forward(ConnectKeystone)
}
private fun onBottomSheetHidden() =
viewModelScope.launch {
bottomSheetHiddenResponse.emit(Unit)
}
private fun onBack() =
viewModelScope.launch {
hideBottomSheet()

View File

@ -15,7 +15,7 @@ import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookItem
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.contact.UpdateContactArgs
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs
import co.electriccoin.zcash.ui.screen.scan.Scan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
@ -85,7 +85,7 @@ class AddressBookViewModel(
private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null))
private fun onScanContactClick() = navigationRouter.forward(ScanNavigationArgs(ScanNavigationArgs.ADDRESS_BOOK))
private fun onScanContactClick() = navigationRouter.forward(Scan(Scan.ADDRESS_BOOK))
}
internal const val ADDRESS_MAX_LENGTH = 20

View File

@ -20,7 +20,7 @@ import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookItem
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs
import co.electriccoin.zcash.ui.screen.scan.Scan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
@ -174,5 +174,5 @@ class SelectRecipientViewModel(
private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null))
private fun onScanContactClick() = navigationRouter.forward(ScanNavigationArgs(ScanNavigationArgs.ADDRESS_BOOK))
private fun onScanContactClick() = navigationRouter.forward(Scan(Scan.ADDRESS_BOOK))
}

View File

@ -1,340 +0,0 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.balances
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.di.koinActivityViewModel
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.LocalActivity
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.SubmitResult
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneShieldProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.screen.balances.model.ShieldState
import co.electriccoin.zcash.ui.screen.balances.model.StatusAction
import co.electriccoin.zcash.ui.screen.balances.view.Balances
import co.electriccoin.zcash.ui.screen.sendconfirmation.viewmodel.CreateTransactionsViewModel
import co.electriccoin.zcash.ui.screen.support.model.SupportInfo
import co.electriccoin.zcash.ui.screen.support.model.SupportInfoType
import co.electriccoin.zcash.ui.screen.support.viewmodel.SupportViewModel
import co.electriccoin.zcash.ui.util.EmailUtil
import co.electriccoin.zcash.ui.util.PlayStoreUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.annotations.VisibleForTesting
import org.koin.compose.koinInject
@Composable
internal fun WrapBalances() {
val activity = LocalActivity.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val createTransactionsViewModel = koinActivityViewModel<CreateTransactionsViewModel>()
val homeViewModel = koinActivityViewModel<HomeViewModel>()
val supportViewModel = koinActivityViewModel<SupportViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val walletSnapshot = walletViewModel.currentWalletSnapshot.collectAsStateWithLifecycle().value
val walletRestoringState = walletViewModel.walletRestoringState.collectAsStateWithLifecycle().value
val isHideBalances = homeViewModel.isHideBalances.collectAsStateWithLifecycle().value ?: false
val balanceState = walletViewModel.balanceState.collectAsStateWithLifecycle().value
val supportInfo = supportViewModel.supportInfo.collectAsStateWithLifecycle().value
val zashiMainTopAppBarViewModel = koinActivityViewModel<ZashiMainTopAppBarViewModel>()
val zashiMainTopAppBarState = zashiMainTopAppBarViewModel.state.collectAsStateWithLifecycle().value
WrapBalances(
balanceState = balanceState,
createTransactionsViewModel = createTransactionsViewModel,
isHideBalances = isHideBalances,
lifecycleScope = activity.lifecycleScope,
supportInfo = supportInfo,
synchronizer = synchronizer,
walletSnapshot = walletSnapshot,
walletRestoringState = walletRestoringState,
zashiMainTopAppBarState = zashiMainTopAppBarState
)
}
const val DEFAULT_SHIELDING_THRESHOLD = 100000L
// This function should be refactored into smaller chunks
@Composable
@VisibleForTesting
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
internal fun WrapBalances(
balanceState: BalanceState,
createTransactionsViewModel: CreateTransactionsViewModel,
lifecycleScope: CoroutineScope,
isHideBalances: Boolean,
supportInfo: SupportInfo?,
synchronizer: Synchronizer?,
walletSnapshot: WalletSnapshot?,
walletRestoringState: WalletRestoringState,
zashiMainTopAppBarState: ZashiMainTopAppBarState?
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val (shieldState, setShieldState) =
rememberSaveable(walletSnapshot?.isZashi, stateSaver = ShieldState.Saver) { mutableStateOf(ShieldState.None) }
// Keep the state always up-to-date with the latest transparent balance
LaunchedEffect(shieldState, walletSnapshot) {
setShieldState(updateTransparentBalanceState(shieldState, walletSnapshot))
}
val (isShowingErrorDialog, setShowErrorDialog) = rememberSaveable { mutableStateOf(false) }
fun showShieldingSuccess() {
setShieldState(ShieldState.Shielded)
Toast.makeText(context, context.getString(R.string.balances_shielding_successful), Toast.LENGTH_LONG).show()
}
suspend fun showShieldingError(shieldingState: ShieldState) {
Twig.error { "Shielding proposal failed with: $shieldingState" }
// Adding the extra delay before notifying UI for a better UX
@Suppress("MagicNumber")
delay(1000)
setShieldState(shieldingState)
setShowErrorDialog(true)
}
// We could also improve this by `rememberSaveable` to preserve the dialog after a configuration change. But the
// dialog dismissing in such cases is not critical, and it would require creating StatusAction custom Saver
val showStatusDialog = remember { mutableStateOf<StatusAction.Detailed?>(null) }
val getZashiSpendingKey = koinInject<GetZashiSpendingKeyUseCase>()
val getSelectedWalletAccount = koinInject<GetSelectedWalletAccountUseCase>()
val createKeystoneShieldProposal = koinInject<CreateKeystoneShieldProposalUseCase>()
if (null == synchronizer || null == walletSnapshot) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
// TODO [#1146]: https://github.com/Electric-Coin-Company/zashi-android/issues/1146
CircularScreenProgressIndicator()
} else {
Balances(
balanceState = balanceState,
isHideBalances = isHideBalances,
isShowingErrorDialog = isShowingErrorDialog,
setShowErrorDialog = setShowErrorDialog,
showStatusDialog = showStatusDialog.value,
hideStatusDialog = { showStatusDialog.value = null },
snackbarHostState = snackbarHostState,
onShielding = {
lifecycleScope.launch {
when (val account = getSelectedWalletAccount()) {
is KeystoneAccount -> {
try {
createKeystoneShieldProposal()
} catch (_: Exception) {
showShieldingError(
ShieldState.Failed(
error =
context.getString(
R.string.balances_shielding_dialog_error_text_below_threshold
),
stackTrace = ""
)
)
}
}
is ZashiAccount -> {
val spendingKey = getZashiSpendingKey()
setShieldState(ShieldState.Running)
Twig.debug { "Shielding transparent funds" }
runCatching {
synchronizer.proposeShielding(
account = account.sdkAccount,
shieldingThreshold = Zatoshi(DEFAULT_SHIELDING_THRESHOLD),
// Using empty string for memo to clear the default memo prefix value defined in
// the SDK
memo = "",
// Using null will select whichever of the account's trans. receivers has funds
// to shield
transparentReceiver = null
)
}.onSuccess { newProposal ->
Twig.info { "Shielding proposal result: ${newProposal?.toPrettyString()}" }
if (newProposal == null) {
showShieldingError(
ShieldState.Failed(
error =
context.getString(
R.string.balances_shielding_dialog_error_text_below_threshold
),
stackTrace = ""
)
)
} else {
val result =
createTransactionsViewModel.runCreateTransactions(
synchronizer = synchronizer,
spendingKey = spendingKey,
proposal = newProposal
)
// Triggering the transaction history and balances refresh to be notified
// immediately about the wallet's updated state
(synchronizer as SdkSynchronizer).run {
refreshTransactions()
refreshAllBalances()
}
when (result) {
is SubmitResult.Success -> {
Twig.info { "Shielding transaction done successfully" }
showShieldingSuccess()
}
is SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc -> {
Twig.warn { "Shielding transaction failed" }
showShieldingError(ShieldState.FailedGrpc)
}
is SubmitResult.SimpleTrxFailure -> {
Twig.warn { "Shielding transaction failed" }
showShieldingError(
ShieldState.Failed(
error = result.toErrorMessage(),
stackTrace = result.toErrorStacktrace()
)
)
}
is SubmitResult.MultipleTrxFailure -> {
Twig.warn {
"Shielding failed with multi-transactions-submission-error handling"
}
}
}
}
}.onFailure {
showShieldingError(
ShieldState.Failed(
error = it.message ?: "Unknown error",
stackTrace = it.stackTraceToString()
)
)
}
}
}
}
},
onStatusClick = { status ->
when (status) {
is StatusAction.Detailed -> showStatusDialog.value = status
StatusAction.AppUpdate -> {
openPlayStoreAppSite(
context = context,
snackbarHostState = snackbarHostState,
scope = scope
)
}
else -> {
// No action required
}
}
},
onContactSupport = { error ->
val fullMessage =
EmailUtil.formatMessage(
body = error,
supportInfo = supportInfo?.toSupportString(SupportInfoType.entries.toSet())
)
val mailIntent =
EmailUtil.newMailActivityIntent(
context.getString(R.string.support_email_address),
context.getString(R.string.app_name),
fullMessage
).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
runCatching {
context.startActivity(mailIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.unable_to_open_email)
)
}
}
},
shieldState = shieldState,
walletSnapshot = walletSnapshot,
walletRestoringState = walletRestoringState,
zashiMainTopAppBarState = zashiMainTopAppBarState
)
}
}
private fun updateTransparentBalanceState(
currentShieldState: ShieldState,
walletSnapshot: WalletSnapshot?
): ShieldState {
return when {
(walletSnapshot == null) -> currentShieldState
(walletSnapshot.transparentBalance >= Zatoshi(DEFAULT_SHIELDING_THRESHOLD) && currentShieldState.isEnabled()) ->
ShieldState.Available
else -> currentShieldState
}
}
private fun openPlayStoreAppSite(
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
) {
val storeIntent = PlayStoreUtil.newActivityIntent(context)
runCatching {
context.startActivity(storeIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.unable_to_open_play_store)
)
}
}
}

View File

@ -0,0 +1,29 @@
package co.electriccoin.zcash.ui.screen.balances
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
sealed interface BalanceState {
val totalBalance: Zatoshi
val spendableBalance: Zatoshi
val exchangeRate: ExchangeRateState
data class None(
override val exchangeRate: ExchangeRateState
) : BalanceState {
override val totalBalance: Zatoshi = Zatoshi(0L)
override val spendableBalance: Zatoshi = Zatoshi(0L)
}
data class Loading(
override val totalBalance: Zatoshi,
override val spendableBalance: Zatoshi,
override val exchangeRate: ExchangeRateState,
) : BalanceState
data class Available(
override val totalBalance: Zatoshi,
override val spendableBalance: Zatoshi,
override val exchangeRate: ExchangeRateState,
) : BalanceState
}

View File

@ -3,6 +3,6 @@ package co.electriccoin.zcash.ui.screen.balances
/**
* These are only used for automated testing.
*/
object BalancesTag {
const val STATUS = "status"
object BalanceTag {
const val BALANCE_VIEWS = "balance_views"
}

View File

@ -0,0 +1,51 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
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 ->
when {
(
account.spendableBalance.value == 0L &&
account.totalBalance.value > 0L &&
(account.hasChangePending || account.hasValuePending)
) -> {
BalanceState.Loading(
totalBalance = account.totalBalance,
spendableBalance = account.spendableBalance,
exchangeRate = exchangeRateUsd,
)
}
else -> {
BalanceState.Available(
totalBalance = account.totalBalance,
spendableBalance = account.spendableBalance,
exchangeRate = exchangeRateUsd,
)
}
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
BalanceState.None(ExchangeRateState.OptedOut)
)
}

View File

@ -0,0 +1,114 @@
package co.electriccoin.zcash.ui.screen.balances
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.extension.toZecStringFull
import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults
import co.electriccoin.zcash.ui.design.component.ZecAmountTriple
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.balances.LocalBalancesAvailable
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.fixture.ObserveFiatCurrencyResultFixture
import co.electriccoin.zcash.ui.screen.balances.BalanceTag.BALANCE_VIEWS
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeBalance
@Composable
fun BalanceWidget(
balanceState: BalanceState,
modifier: Modifier = Modifier
) {
Column(
modifier =
Modifier
.wrapContentSize()
.then(modifier)
.testTag(BALANCE_VIEWS),
horizontalAlignment = Alignment.CenterHorizontally
) {
BalanceWidgetHeader(
parts = balanceState.totalBalance.toZecStringFull().asZecAmountTriple()
)
if (balanceState.exchangeRate is ExchangeRateState.Data) {
Spacer(modifier = Modifier.height(12.dp))
}
StyledExchangeBalance(
zatoshi = balanceState.totalBalance,
state = balanceState.exchangeRate,
)
if (balanceState.exchangeRate is ExchangeRateState.Data) {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
fun BalanceWidgetHeader(
parts: ZecAmountTriple,
modifier: Modifier = Modifier,
isHideBalances: Boolean = LocalBalancesAvailable.current.not(),
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
StyledBalance(
balanceParts = parts,
isHideBalances = isHideBalances,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZashiTypography.header2.copy(fontWeight = FontWeight.SemiBold),
leastSignificantPart = ZashiTypography.textXs.copy(fontWeight = FontWeight.SemiBold),
)
)
Spacer(modifier = Modifier.width(4.dp))
Image(
painter = painterResource(id = R.drawable.ic_zcash_zec_icon),
contentDescription = null,
)
}
}
@PreviewScreens
@Composable
private fun BalanceWidgetPreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface(
modifier = Modifier.fillMaxWidth()
) {
BalanceWidget(
balanceState =
BalanceState.Available(
totalBalance = Zatoshi(1234567891234567L),
spendableBalance = Zatoshi(1234567891234567L),
exchangeRate = ObserveFiatCurrencyResultFixture.new()
),
modifier = Modifier,
)
}
}
}

View File

@ -1,81 +0,0 @@
package co.electriccoin.zcash.ui.screen.balances.model
import androidx.compose.runtime.saveable.mapSaver
sealed class ShieldState {
data object None : ShieldState()
data object Available : ShieldState()
data object Running : ShieldState()
data object Shielded : ShieldState()
data class Failed(
val error: String,
val stackTrace: String,
) : ShieldState()
data object FailedGrpc : ShieldState()
fun isEnabled() = this != Running && this !is Failed && this != Shielded
companion object {
private const val TYPE_NONE = "none" // $NON-NLS
private const val TYPE_AVAILABLE = "available" // $NON-NLS
private const val TYPE_RUNNING = "running" // $NON-NLS
private const val TYPE_SHIELDED = "shielded" // $NON-NLS
private const val TYPE_FAILED = "failed" // $NON-NLS
private const val TYPE_FAILED_GRPC = "failed_grpc" // $NON-NLS
private const val KEY_TYPE = "type" // $NON-NLS
private const val KEY_ERROR = "error" // $NON-NLS
private const val KEY_STACKTRACE = "stacktrace" // $NON-NLS
internal val Saver
get() =
run {
mapSaver(
save = { it.toSaverMap() },
restore = {
if (it.isEmpty()) {
null
} else {
val sendStageString = (it[KEY_TYPE] as String)
when (sendStageString) {
TYPE_NONE -> None
TYPE_AVAILABLE -> Available
TYPE_RUNNING -> Running
TYPE_SHIELDED -> Shielded
TYPE_FAILED ->
Failed(
error = (it[KEY_ERROR] as String),
stackTrace = (it[KEY_STACKTRACE] as String)
)
TYPE_FAILED_GRPC -> FailedGrpc
else -> null
}
}
}
)
}
private fun ShieldState.toSaverMap(): HashMap<String, String> {
val saverMap = HashMap<String, String>()
when (this) {
None -> saverMap[KEY_TYPE] = TYPE_NONE
Available -> saverMap[KEY_TYPE] = TYPE_AVAILABLE
Running -> saverMap[KEY_TYPE] = TYPE_RUNNING
Shielded -> saverMap[KEY_TYPE] = TYPE_SHIELDED
is Failed -> {
saverMap[KEY_TYPE] = TYPE_FAILED
saverMap[KEY_ERROR] = this.error
saverMap[KEY_STACKTRACE] = this.stackTrace
}
FailedGrpc -> saverMap[KEY_TYPE] = TYPE_FAILED_GRPC
}
return saverMap
}
}
}

View File

@ -1,149 +0,0 @@
package co.electriccoin.zcash.ui.screen.balances.model
import android.content.Context
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import cash.z.ecc.android.sdk.model.Locale
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.toFiatCurrencyState
import cash.z.ecc.android.sdk.model.toZecString
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.model.totalBalance
import co.electriccoin.zcash.ui.common.viewmodel.STACKTRACE_LIMIT
data class WalletDisplayValues(
val progress: PercentDecimal,
val zecAmountText: String,
val statusText: String,
val statusAction: StatusAction = StatusAction.None,
val fiatCurrencyAmountState: FiatCurrencyConversionRateState,
val fiatCurrencyAmountText: String
) {
companion object {
@Suppress("LongMethod")
internal fun getNextValues(
context: Context,
walletSnapshot: WalletSnapshot,
): WalletDisplayValues {
var progress = PercentDecimal.ZERO_PERCENT
val zecAmountText = walletSnapshot.totalBalance().toZecString()
var statusText = ""
var statusAction: StatusAction = StatusAction.None
val fiatCurrencyAmountState =
walletSnapshot.spendableBalance().toFiatCurrencyState(
null,
Locale.getDefault(),
)
var fiatCurrencyAmountText = getFiatCurrencyRateValue(context, fiatCurrencyAmountState)
when (walletSnapshot.status) {
Synchronizer.Status.INITIALIZING, Synchronizer.Status.SYNCING -> {
progress = walletSnapshot.progress
// We add "so far" to the amount
if (fiatCurrencyAmountState != FiatCurrencyConversionRateState.Unavailable) {
fiatCurrencyAmountText =
context.getString(
R.string.balances_status_syncing_amount_suffix,
fiatCurrencyAmountText
)
}
statusText = context.getString(R.string.balances_status_syncing)
statusAction = StatusAction.Syncing
}
Synchronizer.Status.SYNCED -> {
statusText = context.getString(R.string.balances_status_synced)
statusAction = StatusAction.Synced
}
Synchronizer.Status.DISCONNECTED -> {
statusText =
context.getString(
R.string.balances_status_error_simple,
context.getString(R.string.app_name)
)
statusAction =
StatusAction.Disconnected(
details = context.getString(R.string.balances_status_error_dialog_connection)
)
}
Synchronizer.Status.STOPPED -> {
statusText = context.getString(R.string.balances_status_syncing)
statusAction =
StatusAction.Stopped(
details = context.getString(R.string.balances_status_dialog_stopped)
)
}
}
// More detailed error message
walletSnapshot.synchronizerError?.let {
statusText =
context.getString(
R.string.balances_status_error_simple,
context.getString(R.string.app_name)
)
statusAction =
StatusAction.Error(
details =
context.getString(
R.string.balances_status_error_dialog_cause,
walletSnapshot.synchronizerError.getCauseMessage()
?: context.getString(R.string.balances_status_error_dialog_cause_unknown),
walletSnapshot.synchronizerError.getStackTrace(limit = STACKTRACE_LIMIT)
?: context.getString(R.string.balances_status_error_dialog_stacktrace_unknown)
),
fullStackTrace = walletSnapshot.synchronizerError.getStackTrace(limit = null)
)
}
return WalletDisplayValues(
progress = progress,
zecAmountText = zecAmountText,
statusAction = statusAction,
statusText = statusText,
fiatCurrencyAmountState = fiatCurrencyAmountState,
fiatCurrencyAmountText = fiatCurrencyAmountText
)
}
}
}
sealed class StatusAction {
data object None : StatusAction()
data object Syncing : StatusAction()
data object Synced : StatusAction()
data object AppUpdate : StatusAction()
sealed class Detailed(open val details: String) : StatusAction()
data class Disconnected(override val details: String) : Detailed(details)
data class Stopped(override val details: String) : Detailed(details)
data class Error(
override val details: String,
val fullStackTrace: String?
) : Detailed(details)
}
fun StatusAction.isReportable() = this is StatusAction.Error && fullStackTrace != null
private fun getFiatCurrencyRateValue(
context: Context,
fiatCurrencyAmountState: FiatCurrencyConversionRateState
): String {
return fiatCurrencyAmountState.let { state ->
when (state) {
is FiatCurrencyConversionRateState.Current -> state.formattedFiatValue
is FiatCurrencyConversionRateState.Stale -> state.formattedFiatValue
is FiatCurrencyConversionRateState.Unavailable -> {
context.getString(R.string.fiat_currency_conversion_rate_unavailable)
}
}
}
}

View File

@ -1,629 +0,0 @@
@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.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.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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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.sdk.extension.DEFAULT_FEE
import cash.z.ecc.sdk.extension.toZecStringFull
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.StatusDialog
import co.electriccoin.zcash.ui.common.compose.SynchronizationStatus
import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple
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.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.BodySmall
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.CircularSmallProgressIndicator
import co.electriccoin.zcash.ui.design.component.Reference
import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.StyledBalance
import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.component.ZashiModal
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
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.StatusAction
@Preview
@Composable
private fun ComposableBalancesPreview() {
ZcashTheme(forceDarkMode = false) {
Balances(
balanceState = BalanceStateFixture.new(),
isHideBalances = false,
isShowingErrorDialog = false,
hideStatusDialog = {},
showStatusDialog = null,
setShowErrorDialog = {},
onShielding = {},
onStatusClick = {},
onContactSupport = {},
shieldState = ShieldState.Available,
snackbarHostState = SnackbarHostState(),
walletSnapshot = WalletSnapshotFixture.new(),
walletRestoringState = WalletRestoringState.NONE,
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new()
)
}
}
@Preview
@Composable
private fun ComposableBalancesShieldDarkPreview() {
ZcashTheme(forceDarkMode = true) {
Balances(
balanceState = BalanceStateFixture.new(),
isHideBalances = false,
isShowingErrorDialog = true,
hideStatusDialog = {},
showStatusDialog = null,
setShowErrorDialog = {},
onShielding = {},
onStatusClick = {},
onContactSupport = {},
shieldState = ShieldState.Available,
snackbarHostState = SnackbarHostState(),
walletSnapshot = WalletSnapshotFixture.new(),
walletRestoringState = WalletRestoringState.NONE,
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new()
)
}
}
@Preview("BalancesShieldErrorDialog")
@Composable
private fun ComposableBalancesShieldErrorDialogPreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface {
ShieldingErrorDialog(
state = ShieldState.Failed("Test Error Text", "Test Error Stacktrace"),
onDone = {},
onReport = {}
)
}
}
}
@Suppress("LongParameterList", "LongMethod")
@Composable
fun Balances(
balanceState: BalanceState,
isHideBalances: Boolean,
isShowingErrorDialog: Boolean,
hideStatusDialog: () -> Unit,
onContactSupport: (String?) -> Unit,
onShielding: () -> Unit,
onStatusClick: (StatusAction) -> Unit,
showStatusDialog: StatusAction.Detailed?,
setShowErrorDialog: (Boolean) -> Unit,
shieldState: ShieldState,
snackbarHostState: SnackbarHostState,
walletSnapshot: WalletSnapshot?,
walletRestoringState: WalletRestoringState,
zashiMainTopAppBarState: ZashiMainTopAppBarState?
) {
BlankBgScaffold(
topBar = {
ZashiMainTopAppBar(zashiMainTopAppBarState)
},
snackbarHost = {
SnackbarHost(snackbarHostState)
},
) { paddingValues ->
if (null == walletSnapshot) {
CircularScreenProgressIndicator()
} else {
BalancesMainContent(
balanceState = balanceState,
isHideBalances = isHideBalances,
onShielding = onShielding,
onStatusClick = onStatusClick,
walletSnapshot = walletSnapshot,
shieldState = shieldState,
modifier =
Modifier.scaffoldPadding(paddingValues),
walletRestoringState = walletRestoringState
)
// Show synchronization status popup
if (showStatusDialog != null) {
StatusDialog(
statusAction = showStatusDialog,
onDone = hideStatusDialog,
onReport = { status ->
hideStatusDialog()
onContactSupport(status.fullStackTrace)
}
)
}
// Show shielding error popup
if (isShowingErrorDialog) {
when (shieldState) {
is ShieldState.Failed -> {
ShieldingErrorDialog(
state = shieldState,
onDone = { setShowErrorDialog(false) },
onReport = { state ->
setShowErrorDialog(false)
onContactSupport(state.stackTrace)
}
)
}
ShieldState.FailedGrpc -> {
ShieldingErrorGrpcDialog(
onDone = { setShowErrorDialog(false) }
)
}
else -> { // Nothing to do now
}
}
}
}
}
}
@Composable
fun ShieldingErrorDialog(
state: ShieldState.Failed,
onDone: () -> Unit,
onReport: (ShieldState.Failed) -> Unit,
) {
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),
color = ZcashTheme.colors.textPrimary,
)
if (state.error.isNotEmpty()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Text(
text = state.error,
fontStyle = FontStyle.Italic,
color = ZcashTheme.colors.textPrimary,
)
}
}
},
confirmButtonText = stringResource(id = R.string.balances_shielding_dialog_error_btn),
onConfirmButtonClick = onDone,
dismissButtonText = stringResource(id = R.string.balances_shielding_dialog_report_btn),
onDismissButtonClick = { onReport(state) },
)
}
@Composable
fun ShieldingErrorGrpcDialog(onDone: () -> Unit) {
AppAlertDialog(
title = stringResource(id = R.string.balances_shielding_dialog_error_grpc_title),
text = {
Column(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
text = stringResource(id = R.string.balances_shielding_dialog_error_grpc_text),
color = ZcashTheme.colors.textPrimary,
)
}
},
confirmButtonText = stringResource(id = R.string.balances_shielding_dialog_error_grpc_btn),
onConfirmButtonClick = onDone
)
}
@Suppress("LongParameterList")
@Composable
private fun BalancesMainContent(
balanceState: BalanceState,
isHideBalances: Boolean,
onShielding: () -> Unit,
onStatusClick: (StatusAction) -> 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,
isHideBalances = isHideBalances,
isReferenceToBalances = false,
onReferenceClick = {}
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
HorizontalDivider(
color = ZcashTheme.colors.tertiaryDividerColor,
thickness = ZcashTheme.dimens.divider
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BalancesOverview(
isHideBalances = isHideBalances,
walletSnapshot = walletSnapshot,
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
TransparentBalancePanel(
isHideBalances = isHideBalances,
onShielding = onShielding,
shieldState = shieldState,
walletSnapshot = walletSnapshot,
)
if (walletRestoringState == WalletRestoringState.RESTORING) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
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.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
} else {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingUpLarge))
}
SynchronizationStatus(
onStatusClick = onStatusClick,
testTag = BalancesTag.STATUS,
walletSnapshot = walletSnapshot,
modifier = Modifier.animateContentSize()
)
}
}
@Composable
fun TransparentBalancePanel(
isHideBalances: Boolean,
onShielding: () -> Unit,
shieldState: ShieldState,
walletSnapshot: WalletSnapshot,
) {
var showHelpPanel by rememberSaveable { mutableStateOf(false) }
ZashiModal {
Column {
TransparentBalanceRow(
isHideBalances = isHideBalances,
isProgressbarVisible = shieldState == ShieldState.Running,
onHelpClick = { showHelpPanel = !showHelpPanel },
walletSnapshot = walletSnapshot
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
ZashiButton(
onClick = onShielding,
text = stringResource(R.string.balances_transparent_balance_shield),
enabled = shieldState == ShieldState.Available,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
BodySmall(
modifier = Modifier.align(Alignment.CenterHorizontally),
text =
stringResource(
id = R.string.balances_transparent_balance_fee,
DEFAULT_FEE
),
textFontWeight = FontWeight.SemiBold
)
}
if (showHelpPanel) {
TransparentBalanceHelpPanel(
onHideHelpPanel = { showHelpPanel = !showHelpPanel }
)
}
}
}
@Composable
fun TransparentBalanceRow(
isHideBalances: Boolean,
isProgressbarVisible: Boolean,
onHelpClick: () -> Unit,
walletSnapshot: WalletSnapshot,
) {
Row(
modifier =
Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// To keep both elements together in relatively sized row
Row(modifier = Modifier.weight(1f)) {
// Apply common click listener
Row(
modifier =
Modifier
.weight(1f)
.clip(RoundedCornerShape(ZcashTheme.dimens.smallRippleEffectCorner))
.clickable { onHelpClick() }
) {
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(
balanceParts = walletSnapshot.transparentBalance.toZecStringFull().asZecAmountTriple(),
isHideBalances = isHideBalances,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.first,
leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textDescriptionDark
)
if (isProgressbarVisible) {
Spacer(modifier = Modifier.width(ZcashTheme.dimens.spacingTiny))
Box(Modifier.width(ZcashTheme.dimens.circularSmallProgressWidth)) {
CircularSmallProgressIndicator()
}
}
}
}
}
@Composable
fun TransparentBalanceHelpPanel(onHideHelpPanel: () -> Unit) {
Column(
modifier =
Modifier
.background(color = ZashiColors.Modals.surfacePrimary)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
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
)
}
}
@Composable
fun BalancesOverview(
walletSnapshot: WalletSnapshot,
isHideBalances: Boolean,
) {
Column {
SpendableBalanceRow(isHideBalances, walletSnapshot)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
ChangePendingRow(isHideBalances, walletSnapshot)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
// aka value pending
PendingTransactionsRow(isHideBalances, walletSnapshot)
}
}
const val TEXT_PART_WIDTH_RATIO = 0.6f
@Composable
fun SpendableBalanceRow(
isHideBalances: Boolean,
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(
balanceParts = walletSnapshot.spendableBalance().toZecStringFull().asZecAmountTriple(),
isHideBalances = isHideBalances,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.first,
leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textPrimary
)
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(
isHideBalances: Boolean,
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(
balanceParts = walletSnapshot.changePendingBalance().toZecStringFull().asZecAmountTriple(),
isHideBalances = isHideBalances,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.first,
leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textDescriptionDark
)
Spacer(modifier = Modifier.width(12.dp))
Box(Modifier.width(ZcashTheme.dimens.circularSmallProgressWidth)) {
if (walletSnapshot.hasChangePending()) {
CircularSmallProgressIndicator()
}
}
}
}
}
@Composable
fun PendingTransactionsRow(
isHideBalances: Boolean,
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(
balanceParts = walletSnapshot.valuePendingBalance().toZecStringFull().asZecAmountTriple(),
isHideBalances = isHideBalances,
textStyle =
StyledBalanceDefaults.textStyles(
mostSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.first,
leastSignificantPart = ZcashTheme.extendedTypography.balanceSingleStyles.second
),
textColor = ZcashTheme.colors.textDescriptionDark
)
Spacer(modifier = Modifier.width(12.dp))
Box(Modifier.width(ZcashTheme.dimens.circularSmallProgressWidth)) {
if (walletSnapshot.hasValuePending()) {
CircularSmallProgressIndicator()
}
}
}
}
}

View File

@ -41,6 +41,7 @@ import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.LottieProgress
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.balances.LocalBalancesAvailable
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.StringResource
@ -56,7 +57,6 @@ fun StyledExchangeBalance(
zatoshi: Zatoshi,
state: ExchangeRateState,
modifier: Modifier = Modifier,
isHideBalances: Boolean = false,
hiddenBalancePlaceholder: StringResource =
stringRes(co.electriccoin.zcash.ui.design.R.string.hide_balance_placeholder),
textColor: Color = ZashiColors.Text.textPrimary,
@ -75,9 +75,9 @@ fun StyledExchangeBalance(
style = style,
textColor = textColor,
zatoshi = zatoshi,
isHideBalances = isHideBalances,
state = state,
hiddenBalancePlaceholder = hiddenBalancePlaceholder
hiddenBalancePlaceholder = hiddenBalancePlaceholder,
isHideBalance = LocalBalancesAvailable.current.not()
)
}
@ -97,9 +97,9 @@ private fun ExchangeAvailableRateLabelInternal(
style: TextStyle,
textColor: Color,
zatoshi: Zatoshi,
isHideBalances: Boolean,
state: ExchangeRateState.Data,
hiddenBalancePlaceholder: StringResource,
isHideBalance: Boolean,
modifier: Modifier = Modifier,
) {
val isEnabled = !state.isLoading && state.isRefreshEnabled
@ -114,7 +114,7 @@ private fun ExchangeAvailableRateLabelInternal(
textColor = textColor,
) {
Text(
text = createExchangeRateText(state, isHideBalances, hiddenBalancePlaceholder, zatoshi),
text = createExchangeRateText(state, hiddenBalancePlaceholder, zatoshi, isHideBalance),
style = style,
maxLines = 1,
color = textColor
@ -148,9 +148,9 @@ private fun ExchangeAvailableRateLabelInternal(
@Composable
internal fun createExchangeRateText(
state: ExchangeRateState.Data,
isHideBalances: Boolean,
hiddenBalancePlaceholder: StringResource,
zatoshi: Zatoshi
zatoshi: Zatoshi,
isHideBalances: Boolean
): String {
val currencySymbol = state.fiatCurrency.symbol
val text =
@ -268,7 +268,6 @@ private fun ExchangeRateButton(
)
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun DefaultPreview() =
@ -276,7 +275,6 @@ private fun DefaultPreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier,
zatoshi = Zatoshi(1),
state =
@ -294,7 +292,6 @@ private fun DefaultPreview() =
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun DefaultNoRefreshPreview() =
@ -302,7 +299,6 @@ private fun DefaultNoRefreshPreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier,
zatoshi = Zatoshi(1),
state =
@ -321,7 +317,6 @@ private fun DefaultNoRefreshPreview() =
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun HiddenPreview() =
@ -329,7 +324,6 @@ private fun HiddenPreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = true,
modifier = Modifier,
zatoshi = Zatoshi(1),
state =
@ -347,7 +341,6 @@ private fun HiddenPreview() =
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun HiddenStalePreview() =
@ -355,7 +348,6 @@ private fun HiddenStalePreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = true,
modifier = Modifier,
zatoshi = Zatoshi(1),
state =
@ -374,7 +366,6 @@ private fun HiddenStalePreview() =
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun LoadingPreview() =
@ -382,7 +373,6 @@ private fun LoadingPreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier,
zatoshi = Zatoshi(1),
state = ObserveFiatCurrencyResultFixture.new()
@ -391,7 +381,6 @@ private fun LoadingPreview() =
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun LoadingEmptyPreview() =
@ -399,7 +388,6 @@ private fun LoadingEmptyPreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier,
zatoshi = Zatoshi(1),
state = ObserveFiatCurrencyResultFixture.new(currencyConversion = null)
@ -408,7 +396,6 @@ private fun LoadingEmptyPreview() =
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun LoadingStalePreview() =
@ -416,7 +403,6 @@ private fun LoadingStalePreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier,
zatoshi = Zatoshi(1),
state =
@ -429,7 +415,6 @@ private fun LoadingStalePreview() =
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun StalePreview() =
@ -437,7 +422,6 @@ private fun StalePreview() =
BlankSurface {
Column {
StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier,
zatoshi = Zatoshi(1),
state =

View File

@ -32,7 +32,7 @@ fun StyledExchangeLabel(
if (!state.isStale && state.currencyConversion != null) {
Text(
modifier = modifier,
text = createExchangeRateText(state, isHideBalances, hiddenBalancePlaceholder, zatoshi),
text = createExchangeRateText(state, hiddenBalancePlaceholder, zatoshi, isHideBalances),
maxLines = 1,
color = textColor,
style = style,

View File

@ -0,0 +1,6 @@
package co.electriccoin.zcash.ui.screen.flexa
import kotlinx.serialization.Serializable
@Serializable
data object Flexa

View File

@ -0,0 +1,16 @@
package co.electriccoin.zcash.ui.screen.flexa
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.electriccoin.zcash.ui.common.usecase.CreateFlexaTransactionUseCase
import com.flexa.spend.Transaction
import kotlinx.coroutines.launch
class FlexaViewModel(
private val createFlexaTransaction: CreateFlexaTransactionUseCase
) : ViewModel() {
fun createTransaction(transaction: Result<Transaction>) =
viewModelScope.launch {
createFlexaTransaction(transaction)
}
}

View File

@ -2,229 +2,36 @@
package co.electriccoin.zcash.ui.screen.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.HomeTabNavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.isSynced
import co.electriccoin.zcash.ui.design.component.RestoreScreenBrightness
import co.electriccoin.zcash.ui.screen.account.WrapAccount
import co.electriccoin.zcash.ui.screen.balances.WrapBalances
import co.electriccoin.zcash.ui.screen.home.model.TabItem
import co.electriccoin.zcash.ui.screen.home.view.Home
import co.electriccoin.zcash.ui.screen.receive.WrapReceive
import co.electriccoin.zcash.ui.screen.send.WrapSend
import co.electriccoin.zcash.ui.screen.send.model.SendArguments
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarViewModel
import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetViewModel
import kotlinx.serialization.Serializable
@Composable
@Suppress("LongParameterList")
internal fun WrapHome(
goScan: () -> Unit,
sendArguments: SendArguments
) {
internal fun AndroidHome() {
val topAppBarViewModel = koinActivityViewModel<ZashiTopAppBarViewModel>()
val balanceViewModel = koinActivityViewModel<BalanceViewModel>()
val homeViewModel = koinActivityViewModel<HomeViewModel>()
val transactionHistoryWidgetViewModel = koinActivityViewModel<TransactionHistoryWidgetViewModel>()
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val appBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
val balanceState by balanceViewModel.state.collectAsStateWithLifecycle()
val state by homeViewModel.state.collectAsStateWithLifecycle()
val transactionWidgetState by transactionHistoryWidgetViewModel.state.collectAsStateWithLifecycle()
val isKeepScreenOnWhileSyncing = homeViewModel.isKeepScreenOnWhileSyncing.collectAsStateWithLifecycle().value
val walletSnapshot = walletViewModel.currentWalletSnapshot.collectAsStateWithLifecycle().value
val walletRestoringState = walletViewModel.walletRestoringState.collectAsStateWithLifecycle().value
// Once the wallet is fully synced and still in restoring state, persist the new state
if (walletSnapshot?.status?.isSynced() == true && walletRestoringState.isRunningRestoring()) {
walletViewModel.persistWalletRestoringState(WalletRestoringState.SYNCING)
}
// TODO [#1523]: Refactor RestoreSuccess screen navigation
// TODO [#1523]: https://github.com/Electric-Coin-Company/zashi-android/issues/1523
val isRestoreSuccessSeen = homeViewModel.isRestoreSuccessSeen.collectAsStateWithLifecycle().value
var isShowingRestoreSuccess by rememberSaveable { mutableStateOf(false) }
val setShowingRestoreSuccess = {
homeViewModel.setRestoringInitialWarningSeen()
isShowingRestoreSuccess = false
}
// Show initial restore success screen
isRestoreSuccessSeen?.let { restoreSuccessSeen ->
if (!restoreSuccessSeen && walletRestoringState == WalletRestoringState.RESTORING) {
LaunchedEffect(key1 = isShowingRestoreSuccess) {
// Adding an extra little delay before displaying for a better UX
@Suppress("MagicNumber")
delay(1500)
isShowingRestoreSuccess = true
}
}
}
WrapHome(
goScan = goScan,
isKeepScreenOnWhileSyncing = isKeepScreenOnWhileSyncing,
isShowingRestoreSuccess = isShowingRestoreSuccess,
sendArguments = sendArguments,
setShowingRestoreSuccess = setShowingRestoreSuccess,
walletSnapshot = walletSnapshot,
)
}
@Suppress("LongParameterList", "LongMethod")
@Composable
internal fun WrapHome(
goScan: () -> Unit,
isKeepScreenOnWhileSyncing: Boolean?,
isShowingRestoreSuccess: Boolean,
sendArguments: SendArguments,
setShowingRestoreSuccess: () -> Unit,
walletSnapshot: WalletSnapshot?,
) {
val activity = LocalActivity.current
val focusManager = LocalFocusManager.current
val homeTabNavigationRouter = koinInject<HomeTabNavigationRouter>()
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val scope = rememberCoroutineScope()
val pagerState =
rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f,
pageCount = { 4 }
state?.let {
HomeView(
appBarState = appBarState,
balanceState = balanceState,
state = it,
transactionWidgetState = transactionWidgetState
)
val homeGoBack: () -> Unit by remember(pagerState.currentPage, scope) {
derivedStateOf {
{
when (pagerState.currentPage) {
HomeScreenIndex.ACCOUNT.pageIndex -> activity.finish()
HomeScreenIndex.SEND.pageIndex,
HomeScreenIndex.RECEIVE.pageIndex,
HomeScreenIndex.BALANCES.pageIndex ->
scope.launch {
pagerState.animateScrollToPage(HomeScreenIndex.ACCOUNT.pageIndex)
}
}
}
}
}
LaunchedEffect(Unit) {
homeTabNavigationRouter.observe().collect {
pagerState.scrollToPage(it.pageIndex)
}
}
BackHandler {
homeGoBack()
}
// Reset the screen brightness for all pages except Receive which maintain the screen brightness by itself
if (pagerState.currentPage != HomeScreenIndex.RECEIVE.pageIndex) {
RestoreScreenBrightness()
}
val tabs =
persistentListOf(
TabItem(
index = HomeScreenIndex.ACCOUNT,
title = stringResource(id = R.string.home_tab_account),
testTag = HomeTag.TAB_ACCOUNT,
screenContent = {
WrapAccount(
goBalances = {
scope.launch {
pagerState.animateScrollToPage(HomeScreenIndex.BALANCES.pageIndex)
}
},
)
}
),
TabItem(
index = HomeScreenIndex.SEND,
title = stringResource(id = R.string.home_tab_send),
testTag = HomeTag.TAB_SEND,
screenContent = {
WrapSend(
sendArguments = sendArguments,
goToQrScanner = goScan,
goBack = homeGoBack,
goBalances = {
scope.launch {
pagerState.animateScrollToPage(HomeScreenIndex.BALANCES.pageIndex)
}
},
)
}
),
TabItem(
index = HomeScreenIndex.RECEIVE,
title = stringResource(id = R.string.home_tab_receive),
testTag = HomeTag.TAB_RECEIVE,
screenContent = {
WrapReceive()
}
),
TabItem(
index = HomeScreenIndex.BALANCES,
title = stringResource(id = R.string.home_tab_balances),
testTag = HomeTag.TAB_BALANCES,
screenContent = {
WrapBalances()
}
)
)
Home(
subScreens = tabs,
isKeepScreenOnWhileSyncing = isKeepScreenOnWhileSyncing,
isShowingRestoreSuccess = isShowingRestoreSuccess,
setShowingRestoreSuccess = setShowingRestoreSuccess,
walletSnapshot = walletSnapshot,
pagerState = pagerState,
)
LaunchedEffect(pagerState.currentPage) {
if (pagerState.currentPage == HomeScreenIndex.SEND.pageIndex) {
walletViewModel.refreshExchangeRateUsd()
} else {
focusManager.clearFocus(true)
}
}
}
/**
* Enum of the Home screen sub-screens
*/
@Suppress("MagicNumber")
enum class HomeScreenIndex(val pageIndex: Int) {
ACCOUNT(0),
SEND(1),
RECEIVE(2),
BALANCES(3)
}
@Serializable
object Home

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.ui.screen.home
import co.electriccoin.zcash.ui.design.component.BigIconButtonState
data class HomeState(
val receiveButton: BigIconButtonState,
val sendButton: BigIconButtonState,
val scanButton: BigIconButtonState,
val moreButton: BigIconButtonState,
)

View File

@ -1,11 +0,0 @@
package co.electriccoin.zcash.ui.screen.home
/**
* These are only used for automated testing.
*/
object HomeTag {
const val TAB_ACCOUNT = "tab_account"
const val TAB_SEND = "tab_send"
const val TAB_RECEIVE = "tab_receive"
const val TAB_BALANCES = "tab_balances"
}

View File

@ -0,0 +1,180 @@
package co.electriccoin.zcash.ui.screen.home
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideOutVertically
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarWithAccountSelection
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.component.BigIconButtonState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
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.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.BalanceWidget
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeOptIn
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetState
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetStateFixture
import co.electriccoin.zcash.ui.screen.transactionhistory.widget.createTransactionHistoryWidgets
@Composable
internal fun HomeView(
appBarState: ZashiMainTopAppBarState?,
balanceState: BalanceState,
transactionWidgetState: TransactionHistoryWidgetState,
state: HomeState
) {
BlankBgScaffold(
topBar = { ZashiTopAppBarWithAccountSelection(appBarState) }
) { paddingValues ->
Content(
modifier = Modifier.padding(top = paddingValues.calculateTopPadding() + 24.dp),
paddingValues = paddingValues,
transactionHistoryWidgetState = transactionWidgetState,
balanceState = balanceState,
state = state
)
}
}
@Composable
private fun Content(
transactionHistoryWidgetState: TransactionHistoryWidgetState,
paddingValues: PaddingValues,
balanceState: BalanceState,
state: HomeState,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BalanceWidget(
modifier =
Modifier
.padding(
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular,
),
balanceState = balanceState,
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
Row(
modifier = Modifier.scaffoldPadding(paddingValues, top = 0.dp, bottom = 0.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ZashiBigIconButton(
modifier = Modifier.weight(1f),
state = state.receiveButton,
)
ZashiBigIconButton(
modifier = Modifier.weight(1f),
state = state.sendButton,
)
ZashiBigIconButton(
modifier = Modifier.weight(1f),
state = state.scanButton,
)
ZashiBigIconButton(
modifier = Modifier.weight(1f),
state = state.moreButton,
)
}
Spacer(Modifier.height(32.dp))
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
) {
createTransactionHistoryWidgets(
state = transactionHistoryWidgetState
)
}
}
AnimatedVisibility(
visible = balanceState.exchangeRate is ExchangeRateState.OptIn,
enter = EnterTransition.None,
exit = fadeOut() + slideOutVertically(),
) {
Column {
Spacer(modifier = Modifier.height(66.dp + paddingValues.calculateTopPadding()))
StyledExchangeOptIn(
modifier = Modifier.padding(horizontal = 24.dp),
state =
(balanceState.exchangeRate as? ExchangeRateState.OptIn) ?: ExchangeRateState.OptIn(
onDismissClick = {},
)
)
}
}
}
}
@PreviewScreens
@Composable
private fun Preview() =
ZcashTheme {
HomeView(
appBarState = ZashiMainTopAppBarStateFixture.new(),
balanceState = BalanceStateFixture.new(),
transactionWidgetState = TransactionHistoryWidgetStateFixture.new(),
state =
HomeState(
receiveButton =
BigIconButtonState(
text = stringRes("Text"),
icon = R.drawable.ic_warning,
onClick = {}
),
sendButton =
BigIconButtonState(
text = stringRes("Text"),
icon = R.drawable.ic_warning,
onClick = {}
),
scanButton =
BigIconButtonState(
text = stringRes("Text"),
icon = R.drawable.ic_warning,
onClick = {}
),
moreButton =
BigIconButtonState(
text = stringRes("Text"),
icon = R.drawable.ic_warning,
onClick = {}
),
)
)
}

View File

@ -0,0 +1,64 @@
package co.electriccoin.zcash.ui.screen.home
import androidx.lifecycle.ViewModel
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.BigIconButtonState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.integrations.DialogIntegrations
import co.electriccoin.zcash.ui.screen.receive.Receive
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.send.Send
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class HomeViewModel(
private val navigationRouter: NavigationRouter,
) : ViewModel() {
val state: StateFlow<HomeState?> =
MutableStateFlow(
HomeState(
receiveButton =
BigIconButtonState(
text = stringRes("Receive"),
icon = R.drawable.ic_home_receive,
onClick = ::onReceiveButtonClick,
),
sendButton =
BigIconButtonState(
text = stringRes("Send"),
icon = R.drawable.ic_home_send,
onClick = ::onSendButtonClick,
),
scanButton =
BigIconButtonState(
text = stringRes("Scan"),
icon = R.drawable.ic_home_scan,
onClick = ::onScanButtonClick,
),
moreButton =
BigIconButtonState(
text = stringRes("More"),
icon = R.drawable.ic_home_more,
onClick = ::onMoreButtonClick,
),
)
).asStateFlow()
private fun onMoreButtonClick() {
navigationRouter.forward(DialogIntegrations)
}
private fun onSendButtonClick() {
navigationRouter.forward(Send())
}
private fun onReceiveButtonClick() {
navigationRouter.forward(Receive)
}
private fun onScanButtonClick() {
navigationRouter.forward(Scan(Scan.HOMEPAGE))
}
}

View File

@ -1,11 +0,0 @@
package co.electriccoin.zcash.ui.screen.home.model
import androidx.compose.runtime.Composable
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
data class TabItem(
val index: HomeScreenIndex,
val title: String,
val testTag: String,
val screenContent: @Composable () -> Unit
)

View File

@ -1,180 +0,0 @@
package co.electriccoin.zcash.ui.screen.home.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import cash.z.ecc.android.sdk.Synchronizer
import co.electriccoin.zcash.ui.common.compose.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.component.BlankSurface
import co.electriccoin.zcash.ui.design.component.NavigationTabText
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.home.model.TabItem
import co.electriccoin.zcash.ui.screen.restoresuccess.WrapRestoreSuccess
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Preview("Home")
@Composable
private fun ComposablePreview() {
ZcashTheme(forceDarkMode = false) {
BlankSurface {
Home(
isKeepScreenOnWhileSyncing = false,
isShowingRestoreSuccess = false,
setShowingRestoreSuccess = {},
subScreens = persistentListOf(),
walletSnapshot = WalletSnapshotFixture.new(),
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongParameterList")
@Composable
fun Home(
isKeepScreenOnWhileSyncing: Boolean?,
isShowingRestoreSuccess: Boolean,
setShowingRestoreSuccess: () -> Unit,
subScreens: ImmutableList<TabItem>,
walletSnapshot: WalletSnapshot?,
pagerState: PagerState =
rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f,
pageCount = { subScreens.size }
)
) {
HomeContent(
pagerState = pagerState,
subScreens = subScreens,
)
if (isShowingRestoreSuccess) {
WrapRestoreSuccess(onDone = setShowingRestoreSuccess)
}
if (isKeepScreenOnWhileSyncing == true &&
walletSnapshot?.status == Synchronizer.Status.SYNCING
) {
DisableScreenTimeout()
}
}
@Composable
@Suppress("LongMethod")
private fun HomeContent(
pagerState: PagerState,
subScreens: ImmutableList<TabItem>
) {
val coroutineScope = rememberCoroutineScope()
ConstraintLayout {
val (pager, tabRow) = createRefs()
HorizontalPager(
state = pagerState,
pageSpacing = 0.dp,
pageSize = PageSize.Fill,
pageNestedScrollConnection =
PagerDefaults.pageNestedScrollConnection(
state = pagerState,
orientation = Orientation.Horizontal
),
pageContent = { index ->
subScreens[index].screenContent()
},
key = { index ->
subScreens[index].title
},
modifier =
Modifier.constrainAs(pager) {
top.linkTo(parent.top)
bottom.linkTo(tabRow.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
)
Column(
modifier =
Modifier.constrainAs(tabRow) {
top.linkTo(pager.bottom)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.wrapContent
}
) {
HorizontalDivider(
thickness = DividerDefaults.Thickness,
color = ZcashTheme.colors.primaryDividerColor
)
TabRow(
selectedTabIndex = pagerState.currentPage,
// Don't use the predefined divider, as its fixed position is below the tabs bar
divider = {},
indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
modifier =
Modifier
.tabIndicatorOffset(tabPositions[pagerState.currentPage])
.padding(horizontal = ZcashTheme.dimens.spacingDefault),
color = ZcashTheme.colors.complementaryColor
)
},
modifier =
Modifier
.navigationBarsPadding()
.padding(
horizontal = ZcashTheme.dimens.spacingDefault,
vertical = ZcashTheme.dimens.spacingSmall
)
) {
subScreens.forEachIndexed { index, item ->
val selected = index == pagerState.currentPage
NavigationTabText(
text = item.title,
selected = selected,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
modifier =
Modifier
.padding(
horizontal = ZcashTheme.dimens.spacingXtiny,
vertical = ZcashTheme.dimens.spacingDefault
)
.testTag(item.testTag)
)
}
}
}
}
}

View File

@ -0,0 +1,60 @@
package co.electriccoin.zcash.ui.screen.integrations
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState
import co.electriccoin.zcash.ui.screen.integrations.view.IntegrationsDialogView
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AndroidDialogIntegrations() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val parent = LocalView.current.parent
val viewModel = koinViewModel<IntegrationsViewModel> { parametersOf(true) }
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(enabled = state != null) {
state?.onBack?.invoke()
}
SideEffect {
(parent as? DialogWindowProvider)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
(parent as? DialogWindowProvider)?.window?.setDimAmount(0f)
}
state?.let {
IntegrationsDialogView(
state = it,
sheetState = sheetState,
onDismissRequest = {
it.onBack()
}
)
LaunchedEffect(Unit) {
sheetState.show()
}
LaunchedEffect(Unit) {
viewModel.hideBottomSheetRequest.collect {
sheetState.hide()
state?.onBottomSheetHidden?.invoke()
}
}
}
}
@Serializable
data object DialogIntegrations

View File

@ -2,39 +2,25 @@ package co.electriccoin.zcash.ui.screen.integrations
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.integrations.view.Integrations
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import com.flexa.core.Flexa
import com.flexa.spend.buildSpend
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
internal fun WrapIntegrations() {
val activity = LocalActivity.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<IntegrationsViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle()
fun AndroidIntegrations() {
val walletViewModel = koinViewModel<WalletViewModel>()
val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value
LaunchedEffect(Unit) {
viewModel.flexaNavigationCommand.collect {
Flexa.buildSpend()
.onTransactionRequest {
viewModel.onFlexaResultCallback(it)
}
.build()
.open(activity)
}
}
val viewModel = koinViewModel<IntegrationsViewModel> { parametersOf(false) }
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler {
viewModel.onBack()
BackHandler(enabled = state != null) {
state?.onBack?.invoke()
}
state?.let {
@ -44,3 +30,6 @@ internal fun WrapIntegrations() {
)
}
}
@Serializable
data object Integrations

View File

@ -8,4 +8,5 @@ data class IntegrationsState(
val disabledInfo: StringResource?,
val onBack: () -> Unit,
val items: ImmutableList<ZashiListItemState>,
val onBottomSheetHidden: () -> Unit,
)

View File

@ -0,0 +1,107 @@
package co.electriccoin.zcash.ui.screen.integrations.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.ZashiModalBottomSheet
import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.component.rememberModalBottomSheetState
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState
import kotlinx.collections.immutable.persistentListOf
@Composable
@OptIn(ExperimentalMaterial3Api::class)
internal fun IntegrationsDialogView(
onDismissRequest: () -> Unit,
sheetState: SheetState,
state: IntegrationsState
) {
ZashiModalBottomSheet(
sheetState = sheetState,
content = {
BottomSheetContent(state)
},
onDismissRequest = onDismissRequest
)
}
@Composable
fun BottomSheetContent(state: IntegrationsState) {
Column {
Text(
modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.integrations_dialog_more_options),
style = ZashiTypography.textXl,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
Spacer(Modifier.height(8.dp))
IntegrationItems(state, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp))
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewScreens
@Composable
private fun IntegrationSettings() =
ZcashTheme {
IntegrationsDialogView(
onDismissRequest = {},
sheetState =
rememberModalBottomSheetState(
skipHiddenState = true,
skipPartiallyExpanded = true,
initialValue = SheetValue.Expanded,
confirmValueChange = { true }
),
state =
IntegrationsState(
onBack = {},
disabledInfo = stringRes("Disabled info"),
items =
persistentListOf(
ZashiListItemState(
icon = R.drawable.ic_integrations_coinbase,
title = stringRes("Coinbase"),
subtitle = stringRes("subtitle"),
onClick = {}
),
ZashiListItemState(
title = stringRes(R.string.integrations_flexa),
subtitle = stringRes(R.string.integrations_flexa),
icon = R.drawable.ic_integrations_flexa,
onClick = {}
),
ZashiListItemState(
title = stringRes(R.string.integrations_keystone),
subtitle = stringRes(R.string.integrations_keystone_subtitle),
icon = R.drawable.ic_integrations_keystone,
onClick = {}
),
),
onBottomSheetHidden = {}
),
)
}

View File

@ -2,6 +2,7 @@ package co.electriccoin.zcash.ui.screen.integrations.view
import androidx.compose.foundation.Image
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.fillMaxSize
@ -64,24 +65,7 @@ fun Integrations(
.verticalScroll(rememberScrollState())
.scaffoldScrollPadding(paddingValues),
) {
state.items.forEachIndexed { index, item ->
ZashiListItem(
state = item,
modifier = Modifier.padding(horizontal = 4.dp),
leading = {
ZashiListItemDefaults.LeadingItem(
modifier = Modifier.size(40.dp),
icon = item.icon,
contentDescription = item.title.getValue()
)
},
)
if (index != state.items.lastIndex) {
ZashiHorizontalDivider(
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
IntegrationItems(state)
state.disabledInfo?.let {
Spacer(modifier = Modifier.height(28.dp))
@ -114,6 +98,32 @@ fun Integrations(
}
}
@Composable
fun IntegrationItems(
state: IntegrationsState,
contentPadding: PaddingValues = ZashiListItemDefaults.contentPadding
) {
state.items.forEachIndexed { index, item ->
ZashiListItem(
state = item,
modifier = Modifier.padding(horizontal = 4.dp),
leading = {
ZashiListItemDefaults.LeadingItem(
modifier = Modifier.size(40.dp),
icon = item.icon,
contentDescription = item.title.getValue()
)
},
contentPadding = contentPadding
)
if (index != state.items.lastIndex) {
ZashiHorizontalDivider(
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
}
@Composable
private fun DisabledInfo(it: StringResource) {
Row(
@ -187,7 +197,8 @@ private fun IntegrationSettings() =
icon = R.drawable.ic_integrations_keystone,
onClick = {}
),
)
),
onBottomSheetHidden = {}
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)

View File

@ -1,85 +1,60 @@
package co.electriccoin.zcash.ui.screen.integrations.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Proposal
import cash.z.ecc.android.sdk.model.TransactionSubmitResult
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.ZecSendExt
import cash.z.ecc.android.sdk.model.proposeSend
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.KeystoneAccount
import co.electriccoin.zcash.ui.common.model.SubmitResult
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.repository.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricRequest
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase
import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.connectkeystone.ConnectKeystone
import co.electriccoin.zcash.ui.screen.flexa.Flexa
import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import com.flexa.core.Flexa
import com.flexa.spend.Transaction
import com.flexa.spend.buildSpend
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class IntegrationsViewModel(
getZcashCurrency: GetZcashCurrencyProvider,
observeWalletState: ObserveWalletStateUseCase,
getWalletRestoringState: GetWalletRestoringStateUseCase,
isFlexaAvailableUseCase: IsFlexaAvailableUseCase,
isCoinbaseAvailable: IsCoinbaseAvailableUseCase,
private val getSynchronizer: GetSynchronizerUseCase,
private val getZashiAccount: GetZashiAccountUseCase,
private val isFlexaAvailable: IsFlexaAvailableUseCase,
private val getSpendingKey: GetZashiSpendingKeyUseCase,
private val context: Context,
private val biometricRepository: BiometricRepository,
observeWalletAccounts: ObserveWalletAccountsUseCase,
private val isDialog: Boolean,
private val navigationRouter: NavigationRouter,
private val navigateToCoinbase: NavigateToCoinbaseUseCase,
private val observeWalletAccounts: ObserveWalletAccountsUseCase,
) : ViewModel() {
val flexaNavigationCommand = MutableSharedFlow<Unit>()
val hideBottomSheetRequest = MutableSharedFlow<Unit>()
private val isEnabled =
observeWalletState()
.map {
it != TopAppBarSubTitleState.Restoring
}
private val bottomSheetHiddenResponse = MutableSharedFlow<Unit>()
private val isRestoring = getWalletRestoringState.observe().map { it == WalletRestoringState.RESTORING }
val state =
combine(
isFlexaAvailableUseCase.observe(),
isCoinbaseAvailable.observe(),
isEnabled,
isRestoring,
observeWalletAccounts()
) { isFlexaAvailable, isCoinbaseAvailable, isEnabled, accounts ->
) { isFlexaAvailable, isCoinbaseAvailable, isRestoring, accounts ->
IntegrationsState(
disabledInfo = stringRes(R.string.integrations_disabled_info).takeIf { isEnabled.not() },
disabledInfo =
stringRes(R.string.integrations_disabled_info)
.takeIf { isRestoring },
onBack = ::onBack,
items =
listOfNotNull(
@ -98,9 +73,9 @@ class IntegrationsViewModel(
ZashiListItemState(
// Set the wallet currency by app build is more future-proof, although we hide it from
// the UI in the Testnet build
isEnabled = isEnabled,
isEnabled = isRestoring.not(),
icon =
if (isEnabled) {
if (isRestoring.not()) {
R.drawable.ic_integrations_flexa
} else {
R.drawable.ic_integrations_flexa_disabled
@ -115,7 +90,8 @@ class IntegrationsViewModel(
icon = R.drawable.ic_integrations_keystone,
onClick = ::onConnectKeystoneClick
).takeIf { accounts.orEmpty().none { it is KeystoneAccount } },
).toImmutableList()
).toImmutableList(),
onBottomSheetHidden = ::onBottomSheetHidden
)
}.stateIn(
scope = viewModelScope,
@ -123,186 +99,40 @@ class IntegrationsViewModel(
initialValue = null
)
fun onBack() = navigationRouter.back()
private fun onBack() = navigationRouter.back()
private suspend fun hideBottomSheet() {
if (isDialog) {
hideBottomSheetRequest.emit(Unit)
bottomSheetHiddenResponse.first()
}
}
private fun onBottomSheetHidden() =
viewModelScope.launch {
bottomSheetHiddenResponse.emit(Unit)
}
private fun onBuyWithCoinbaseClicked() =
viewModelScope.launch {
hideBottomSheet()
navigateToCoinbase()
}
private fun onConnectKeystoneClick() = navigationRouter.forward(ConnectKeystone)
private fun onConnectKeystoneClick() =
viewModelScope.launch {
hideBottomSheet()
navigationRouter.forward(ConnectKeystone)
}
private fun onFlexaClicked() =
viewModelScope.launch {
if (isFlexaAvailable()) {
flexaNavigationCommand.emit(Unit)
}
}
fun onFlexaResultCallback(transaction: Result<Transaction>) =
viewModelScope.launch {
runCatching {
biometricRepository.requestBiometrics(
BiometricRequest(message = stringRes(R.string.integrations_flexa_biometric_message))
)
Twig.debug { "Getting send transaction proposal" }
getSynchronizer()
.proposeSend(
account = getZashiAccount().sdkAccount,
send = getZecSend(transaction.getOrNull())
)
}.onSuccess { proposal ->
Twig.debug { "Transaction proposal successful: ${proposal.toPrettyString()}" }
val result = submitTransactions(proposal = proposal, spendingKey = getSpendingKey())
when (val output = result.first) {
is SubmitResult.Success -> {
Twig.debug { "Transaction successful $result" }
Flexa.buildSpend()
.transactionSent(
commerceSessionId = transaction.getOrNull()?.commerceSessionId.orEmpty(),
txSignature = result.second.orEmpty()
)
}
is SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc -> {
Twig.warn { "Transaction grpc failure $result" }
Flexa.buildSpend()
.transactionSent(
commerceSessionId = transaction.getOrNull()?.commerceSessionId.orEmpty(),
txSignature = output.result.txIdString()
)
}
else -> {
Twig.error { "Transaction submission failed" }
}
}
}.onFailure {
Twig.error(it) { "Transaction proposal failed" }
}
}
private suspend fun submitTransactions(
proposal: Proposal,
spendingKey: UnifiedSpendingKey
): Pair<SubmitResult, String?> {
Twig.debug { "Sending transactions..." }
val result =
runCreateTransactions(
synchronizer = getSynchronizer(),
spendingKey = spendingKey,
proposal = proposal
)
// Triggering the transaction history and balances refresh to be notified immediately
// about the wallet's updated state
(getSynchronizer() as SdkSynchronizer).run {
refreshTransactions()
refreshAllBalances()
}
return result
}
private suspend fun runCreateTransactions(
synchronizer: Synchronizer,
spendingKey: UnifiedSpendingKey,
proposal: Proposal
): Pair<SubmitResult, String?> {
val submitResults = mutableListOf<TransactionSubmitResult>()
return runCatching {
synchronizer.createProposedTransactions(
proposal = proposal,
usk = spendingKey
).collect { submitResult ->
Twig.info { "Transaction submit result: $submitResult" }
submitResults.add(submitResult)
}
if (submitResults.find { it is TransactionSubmitResult.Failure } != null) {
if (submitResults.size == 1) {
// The first transaction submission failed - user might just be able to re-submit the transaction
// proposal. Simple error pop up is fine then
val result = (submitResults[0] as TransactionSubmitResult.Failure)
if (result.grpcError) {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc(result) to null
} else {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureSubmit(result) to null
}
} else {
// Any subsequent transaction submission failed - user needs to resolve this manually. Multiple
// transaction failure screen presented
SubmitResult.MultipleTrxFailure(submitResults) to null
}
if (isDialog) {
hideBottomSheet()
navigationRouter.replace(Flexa)
} else {
// All transaction submissions were successful
SubmitResult.Success(emptyList()) to
submitResults.filterIsInstance<TransactionSubmitResult.Success>()
.map { it.txIdString() }.firstOrNull()
}
}.onSuccess {
Twig.debug { "Transactions submitted successfully" }
}.onFailure {
Twig.error(it) { "Transactions submission failed" }
}.getOrElse {
SubmitResult.SimpleTrxFailure.SimpleTrxFailureOther(it) to null
}
}
@Suppress("TooGenericExceptionThrown")
private suspend fun getZecSend(transaction: Transaction?): ZecSend {
if (transaction == null) throw NullPointerException("Transaction is null")
val address = transaction.destinationAddress.split(":").last()
val recipientAddressState =
RecipientAddressState.new(
address = address,
// TODO [#342]: Verify Addresses without Synchronizer
// TODO [#342]: https://github.com/zcash/zcash-android-wallet-sdk/issues/342
type = getSynchronizer().validateAddress(address)
)
return when (
val zecSendValidation =
ZecSendExt.new(
context = context,
destinationString = address,
zecString = transaction.amount,
// Take memo for a valid non-transparent receiver only
memoString = ""
)
) {
is ZecSendExt.ZecSendValidation.Valid ->
zecSendValidation.zecSend.copy(
destination =
when (recipientAddressState.type) {
is AddressType.Invalid ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Shielded ->
WalletAddress.Unified.new(recipientAddressState.address)
AddressType.Tex ->
WalletAddress.Tex.new(recipientAddressState.address)
AddressType.Transparent ->
WalletAddress.Transparent.new(recipientAddressState.address)
AddressType.Unified ->
WalletAddress.Unified.new(recipientAddressState.address)
null -> WalletAddress.Unified.new(recipientAddressState.address)
}
)
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}" }
throw RuntimeException("Validation failed")
hideBottomSheet()
navigationRouter.forward(Flexa)
}
}
}
}

View File

@ -2,23 +2,28 @@
package co.electriccoin.zcash.ui.screen.receive
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarViewModel
import co.electriccoin.zcash.ui.screen.receive.view.ReceiveView
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
@Composable
internal fun WrapReceive() {
internal fun AndroidReceive() {
val receiveViewModel = koinActivityViewModel<ReceiveViewModel>()
val receiveState by receiveViewModel.state.collectAsStateWithLifecycle()
val topAppBarViewModel = koinActivityViewModel<ZashiMainTopAppBarViewModel>()
val zashiMainTopAppBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
val state by receiveViewModel.state.collectAsStateWithLifecycle()
val topAppBarViewModel = koinActivityViewModel<ZashiTopAppBarViewModel>()
val appBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
BackHandler {
state.onBack()
}
ReceiveView(
state = receiveState,
zashiMainTopAppBarState = zashiMainTopAppBarState,
state = state,
appBarState = appBarState,
)
}

View File

@ -0,0 +1,6 @@
package co.electriccoin.zcash.ui.screen.receive
import kotlinx.serialization.Serializable
@Serializable
data object Receive

View File

@ -4,7 +4,8 @@ import co.electriccoin.zcash.ui.design.util.StringResource
data class ReceiveState(
val items: List<ReceiveAddressState>?,
val isLoading: Boolean
val isLoading: Boolean,
val onBack: () -> Unit
)
data class ReceiveAddressState(

View File

@ -31,16 +31,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppbar
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
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.getValue
import co.electriccoin.zcash.ui.design.util.scaffoldScrollPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressState
@ -49,7 +50,7 @@ import co.electriccoin.zcash.ui.screen.receive.model.ReceiveState
@Composable
internal fun ReceiveView(
state: ReceiveState,
zashiMainTopAppBarState: ZashiMainTopAppBarState?,
appBarState: ZashiMainTopAppBarState?,
) {
when {
state.items.isNullOrEmpty() && state.isLoading -> {
@ -59,15 +60,20 @@ internal fun ReceiveView(
else -> {
BlankBgScaffold(
topBar = {
ZashiMainTopAppBar(state = zashiMainTopAppBarState, showHideBalances = false)
ZashiTopAppbar(
title = null,
state = appBarState,
showHideBalances = false,
onBack = state.onBack
)
},
) { paddingValues ->
ReceiveContents(
items = state.items.orEmpty(),
modifier =
Modifier.padding(
Modifier.scaffoldScrollPadding(
paddingValues = paddingValues,
top = paddingValues.calculateTopPadding()
// We intentionally do not set the rest paddings, those are set by the underlying composable
),
)
}
@ -266,8 +272,13 @@ private fun ReceiveIconButton(
private fun LoadingPreview() =
ZcashTheme(forceDarkMode = true) {
ReceiveView(
state = ReceiveState(items = null, isLoading = true),
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new()
state =
ReceiveState(
items = null,
isLoading = true,
onBack = {}
),
appBarState = ZashiMainTopAppBarStateFixture.new()
)
}
@ -303,8 +314,9 @@ private fun ZashiPreview() =
onClick = {}
)
),
isLoading = false
isLoading = false,
onBack = {}
),
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new()
appBarState = ZashiMainTopAppBarStateFixture.new()
)
}

View File

@ -55,12 +55,18 @@ class ReceiveViewModel(
onClick = { onAddressClick(1) }
),
),
isLoading = false
isLoading = false,
onBack = ::onBack
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = ReceiveState(items = null, isLoading = true)
initialValue =
ReceiveState(
items = null,
isLoading = true,
onBack = ::onBack
)
)
init {
@ -71,6 +77,8 @@ class ReceiveViewModel(
}
}
private fun onBack() = navigationRouter.back()
private fun createAddressState(
account: WalletAccount,
address: String,
@ -96,6 +104,7 @@ class ReceiveViewModel(
} else {
stringRes(R.string.receive_wallet_address_transparent_keystone)
}
is ZashiAccount ->
if (type == ReceiveAddressType.Unified) {
stringRes(R.string.receive_wallet_address_shielded)

View File

@ -27,7 +27,6 @@ import androidx.compose.ui.unit.sp
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.sdk.extension.toZecStringFull
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import co.electriccoin.zcash.ui.common.compose.BalanceWidgetBigLineOnly
import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.R
@ -51,6 +50,7 @@ import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.balances.BalanceWidgetHeader
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeLabel
import kotlinx.datetime.Clock
@ -340,7 +340,7 @@ private fun AmountWidget(state: AmountState) {
color = ZashiColors.Text.textPrimary
)
}
BalanceWidgetBigLineOnly(
BalanceWidgetHeader(
parts = state.amount.toZecStringFull().asZecAmountTriple(),
isHideBalances = false
)

View File

@ -3,21 +3,17 @@ package co.electriccoin.zcash.ui.screen.scan
import androidx.activity.compose.BackHandler
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_ZIP_321_URI
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.popBackStackJustOnce
import co.electriccoin.zcash.ui.screen.scan.model.ScanResultState
import co.electriccoin.zcash.ui.screen.scan.view.Scan
import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel
import co.electriccoin.zcash.ui.util.SettingsUtil
@ -26,7 +22,7 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
internal fun WrapScanValidator(args: ScanNavigationArgs) {
internal fun WrapScanValidator(args: Scan) {
val navController = LocalNavController.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
@ -38,26 +34,7 @@ internal fun WrapScanValidator(args: ScanNavigationArgs) {
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler {
navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE)
}
LaunchedEffect(Unit) {
viewModel.navigateBack.collect { scanResult ->
navController.previousBackStackEntry?.savedStateHandle?.apply {
when (scanResult) {
is ScanResultState.Address -> set(SEND_SCAN_RECIPIENT_ADDRESS, scanResult.address)
is ScanResultState.Zip321Uri -> set(SEND_SCAN_ZIP_321_URI, scanResult.zip321Uri)
}
}
navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE)
}
}
LaunchedEffect(Unit) {
viewModel.navigateCommand.collect {
navController.popBackStack()
navController.navigate(it)
}
navController.popBackStackJustOnce(Scan.ROUTE)
}
if (synchronizer == null) {
@ -69,7 +46,7 @@ internal fun WrapScanValidator(args: ScanNavigationArgs) {
Scan(
snackbarHostState = snackbarHostState,
validationResult = state,
onBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) },
onBack = { navController.popBackStackJustOnce(Scan.ROUTE) },
onScanned = {
viewModel.onScanned(it)
},
@ -95,8 +72,9 @@ internal fun WrapScanValidator(args: ScanNavigationArgs) {
}
}
enum class ScanNavigationArgs {
DEFAULT,
enum class Scan {
HOMEPAGE,
SEND,
ADDRESS_BOOK;
companion object {
@ -104,6 +82,6 @@ enum class ScanNavigationArgs {
const val KEY = "mode"
const val ROUTE = "$PATH/{$KEY}"
operator fun invoke(mode: ScanNavigationArgs) = "$PATH/${mode.name}"
operator fun invoke(mode: Scan) = "$PATH/${mode.name}"
}
}

View File

@ -1,7 +0,0 @@
package co.electriccoin.zcash.ui.screen.scan.model
sealed class ScanResultState {
data class Address(val address: String) : ScanResultState()
data class Zip321Uri(val zip321Uri: String) : ScanResultState()
}

View File

@ -3,34 +3,27 @@ package co.electriccoin.zcash.ui.screen.scan.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.common.model.SerializableAddress
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.OnAddressScannedUseCase
import co.electriccoin.zcash.ui.common.usecase.OnZip321ScannedUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase.Zip321ParseUriValidation
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs.ADDRESS_BOOK
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs.DEFAULT
import co.electriccoin.zcash.ui.screen.scan.model.ScanResultState
import co.electriccoin.zcash.ui.screen.scan.Scan
import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
internal class ScanViewModel(
private val args: ScanNavigationArgs,
private val args: Scan,
private val getSynchronizer: GetSynchronizerUseCase,
private val zip321ParseUriValidationUseCase: Zip321ParseUriValidationUseCase,
private val onAddressScanned: OnAddressScannedUseCase,
private val zip321Scanned: OnZip321ScannedUseCase
) : ViewModel() {
val navigateBack = MutableSharedFlow<ScanResultState>()
val navigateCommand = MutableSharedFlow<String>()
var state = MutableStateFlow(ScanValidationState.NONE)
val state = MutableStateFlow(ScanValidationState.NONE)
private val mutex = Mutex()
@ -40,77 +33,58 @@ internal class ScanViewModel(
viewModelScope.launch {
mutex.withLock {
if (!hasBeenScannedSuccessfully) {
val addressValidationResult = getSynchronizer().validateAddress(result)
val zip321ValidationResult = zip321ParseUriValidationUseCase(result)
runCatching {
val zip321ValidationResult = zip321ParseUriValidationUseCase(result)
val addressValidationResult = getSynchronizer().validateAddress(result)
when {
zip321ValidationResult is Zip321ParseUriValidation.Valid ->
{
hasBeenScannedSuccessfully = true
state.update { ScanValidationState.VALID }
navigateBack.emit(ScanResultState.Zip321Uri(zip321ValidationResult.zip321Uri))
}
zip321ValidationResult is Zip321ParseUriValidation.SingleAddress ->
{
hasBeenScannedSuccessfully = true
val singleAddressValidation =
getSynchronizer()
.validateAddress(zip321ValidationResult.address)
when (singleAddressValidation) {
is AddressType.Invalid -> {
state.update { ScanValidationState.INVALID }
}
else -> {
state.update { ScanValidationState.VALID }
processAddress(zip321ValidationResult.address, singleAddressValidation)
}
}
}
addressValidationResult is AddressType.Valid ->
{
hasBeenScannedSuccessfully = true
state.update { ScanValidationState.VALID }
processAddress(result, addressValidationResult)
}
else -> {
hasBeenScannedSuccessfully = false
state.update { ScanValidationState.INVALID }
when {
zip321ValidationResult is Zip321ParseUriValidation.Valid ->
onZip321Scanned(zip321ValidationResult)
zip321ValidationResult is Zip321ParseUriValidation.SingleAddress ->
onZip321SingleAddressScanned(zip321ValidationResult)
addressValidationResult is AddressType.Valid ->
onAddressScanned(result, addressValidationResult)
else -> onInvalidScan()
}
}
}
}
}
private suspend fun processAddress(
address: String,
addressType: AddressType
private fun onInvalidScan() {
hasBeenScannedSuccessfully = false
state.update { ScanValidationState.INVALID }
}
private fun onAddressScanned(
result: String,
addressValidationResult: AddressType
) {
require(addressType is AddressType.Valid)
hasBeenScannedSuccessfully = true
state.update { ScanValidationState.VALID }
onAddressScanned(result, addressValidationResult, args)
}
val serializableAddress =
SerializableAddress(
address = address,
type = addressType
)
when (args) {
DEFAULT -> {
navigateBack.emit(
ScanResultState.Address(
Json.encodeToString(
SerializableAddress.serializer(),
serializableAddress
)
)
)
}
ADDRESS_BOOK -> {
navigateCommand.emit(AddContactArgs(serializableAddress.address))
}
private suspend fun onZip321SingleAddressScanned(zip321ValidationResult: Zip321ParseUriValidation.SingleAddress) {
hasBeenScannedSuccessfully = true
val singleAddressValidation = getSynchronizer().validateAddress(zip321ValidationResult.address)
if (singleAddressValidation is AddressType.Invalid) {
state.update { ScanValidationState.INVALID }
} else {
state.update { ScanValidationState.VALID }
onAddressScanned(zip321ValidationResult.address, singleAddressValidation, args)
}
}
private suspend fun onZip321Scanned(zip321ValidationResult: Zip321ParseUriValidation.Valid) {
hasBeenScannedSuccessfully = true
state.update { ScanValidationState.VALID }
zip321Scanned(zip321ValidationResult, args)
}
fun onScannedError() =
viewModelScope.launch {
mutex.withLock {

View File

@ -19,22 +19,25 @@ import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarViewModel
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.usecase.ObserveClearSendUseCase
import co.electriccoin.zcash.ui.common.usecase.PrefillSendData
import co.electriccoin.zcash.ui.common.usecase.PrefillSendUseCase
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
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.scan.Scan
import co.electriccoin.zcash.ui.screen.send.ext.Saver
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.SendArguments
import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.launch
@ -43,45 +46,39 @@ import org.koin.compose.koinInject
import java.util.Locale
@Composable
@Suppress("LongParameterList")
internal fun WrapSend(
sendArguments: SendArguments?,
goToQrScanner: () -> Unit,
goBack: () -> Unit,
goBalances: () -> Unit,
) {
internal fun WrapSend(args: Send) {
val activity = LocalActivity.current
val navigationRouter = koinInject<NavigationRouter>()
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val homeViewModel = koinActivityViewModel<HomeViewModel>()
val balanceViewModel = koinActivityViewModel<BalanceViewModel>()
val accountDataSource = koinInject<AccountDataSource>()
val hasCameraFeature = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val walletSnapshot = walletViewModel.currentWalletSnapshot.collectAsStateWithLifecycle().value
val selectedAccount = accountDataSource.selectedAccount.collectAsStateWithLifecycle(null).value
val monetarySeparators = MonetarySeparators.current(Locale.getDefault())
val balanceState = walletViewModel.balanceState.collectAsStateWithLifecycle().value
val isHideBalances = homeViewModel.isHideBalances.collectAsStateWithLifecycle().value ?: false
val balanceState = balanceViewModel.state.collectAsStateWithLifecycle().value
val exchangeRateState = walletViewModel.exchangeRateUsd.collectAsStateWithLifecycle().value
WrapSend(
balanceState = balanceState,
exchangeRateState = exchangeRateState,
isHideBalances = isHideBalances,
goToQrScanner = goToQrScanner,
goBack = goBack,
goBalances = goBalances,
goToQrScanner = { navigationRouter.forward(Scan(Scan.SEND)) },
goBack = { navigationRouter.back() },
hasCameraFeature = hasCameraFeature,
monetarySeparators = monetarySeparators,
sendArguments = sendArguments,
sendArguments = args,
synchronizer = synchronizer,
walletSnapshot = walletSnapshot
selectedAccount = selectedAccount
)
}
@ -91,15 +88,13 @@ internal fun WrapSend(
internal fun WrapSend(
balanceState: BalanceState,
exchangeRateState: ExchangeRateState,
isHideBalances: Boolean,
goToQrScanner: () -> Unit,
goBack: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean,
monetarySeparators: MonetarySeparators,
sendArguments: SendArguments?,
sendArguments: Send,
synchronizer: Synchronizer?,
walletSnapshot: WalletSnapshot?,
selectedAccount: WalletAccount?,
) {
val scope = rememberCoroutineScope()
@ -115,7 +110,7 @@ internal fun WrapSend(
val sendAddressBookState by viewModel.sendAddressBookState.collectAsStateWithLifecycle()
val topAppBarViewModel = koinActivityViewModel<ZashiMainTopAppBarViewModel>()
val topAppBarViewModel = koinActivityViewModel<ZashiTopAppBarViewModel>()
val zashiMainTopAppBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
@ -131,27 +126,20 @@ internal fun WrapSend(
val observeClearSend = koinInject<ObserveClearSendUseCase>()
val prefillSend = koinInject<PrefillSendUseCase>()
if (sendArguments?.recipientAddress != null) {
if (sendArguments.recipientAddress != null && sendArguments.recipientAddressType != null) {
viewModel.onRecipientAddressChanged(
RecipientAddressState.new(
sendArguments.recipientAddress.address,
sendArguments.recipientAddress.type
sendArguments.recipientAddress,
when (sendArguments.recipientAddressType) {
cash.z.ecc.sdk.model.AddressType.UNIFIED -> AddressType.Unified
cash.z.ecc.sdk.model.AddressType.TRANSPARENT -> AddressType.Transparent
cash.z.ecc.sdk.model.AddressType.SAPLING -> AddressType.Shielded
cash.z.ecc.sdk.model.AddressType.TEX -> AddressType.Tex
}
)
)
}
// Zip321 Uri scan result processing
if (sendArguments?.zip321Uri != null &&
synchronizer != null
) {
LaunchedEffect(Unit) {
viewModel.onCreateZecSend321Click(
zip321Uri = sendArguments.zip321Uri,
setSendStage = setSendStage,
)
}
}
// Amount computation:
val (amountState, setAmountState) =
rememberSaveable(stateSaver = AmountState.Saver) {
@ -226,51 +214,48 @@ internal fun WrapSend(
LaunchedEffect(Unit) {
prefillSend().collect {
val type = synchronizer?.validateAddress(it.recipientAddress?.address.orEmpty())
setSendStage(SendStage.Form)
setZecSend(null)
viewModel.onRecipientAddressChanged(
RecipientAddressState.new(
address = it.recipientAddress?.address.orEmpty(),
type = type
)
)
when (it) {
is PrefillSendData.All -> {
val type = synchronizer?.validateAddress(it.address.orEmpty())
setSendStage(SendStage.Form)
setZecSend(null)
viewModel.onRecipientAddressChanged(
RecipientAddressState.new(
address = it.address.orEmpty(),
type = type
)
)
val fee = it.transaction.fee
val value = if (fee == null) it.transaction.amount else it.transaction.amount - fee
val fee = it.fee
val value = if (fee == null) it.amount else it.amount - fee
setAmountState(
AmountState.newFromZec(
context = context,
value = value.convertZatoshiToZecString(),
monetarySeparators = monetarySeparators,
isTransparentOrTextRecipient = type == AddressType.Transparent,
fiatValue = amountState.fiatValue,
exchangeRateState = exchangeRateState
)
)
setMemoState(MemoState.new(it.memos?.firstOrNull().orEmpty()))
setAmountState(
AmountState.newFromZec(
context = context,
value = value.convertZatoshiToZecString(),
monetarySeparators = monetarySeparators,
isTransparentOrTextRecipient = type == AddressType.Transparent,
fiatValue = amountState.fiatValue,
exchangeRateState = exchangeRateState
)
)
setMemoState(MemoState.new(it.memos?.firstOrNull().orEmpty()))
}
is PrefillSendData.FromAddressScan -> {
val type = synchronizer?.validateAddress(it.address)
setSendStage(SendStage.Form)
setZecSend(null)
viewModel.onRecipientAddressChanged(
RecipientAddressState.new(
address = it.address,
type = type
)
)
}
}
}
}
// Clearing form from the previous navigation destination if required
if (sendArguments?.clearForm == true) {
setSendStage(SendStage.Form)
setZecSend(null)
viewModel.onRecipientAddressChanged(RecipientAddressState.new("", null))
setAmountState(
AmountState.newFromZec(
context = context,
monetarySeparators = monetarySeparators,
value = "",
fiatValue = "",
isTransparentOrTextRecipient = false,
exchangeRateState = exchangeRateState
)
)
setMemoState(MemoState.new(""))
}
val onBackAction = {
when (sendStage) {
SendStage.Form -> goBack()
@ -282,7 +267,7 @@ internal fun WrapSend(
}
}
if (null == synchronizer || null == walletSnapshot) {
if (null == synchronizer || null == selectedAccount) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
// TODO [#1146]: https://github.com/Electric-Coin-Company/zashi-android/issues/1146
@ -290,7 +275,6 @@ internal fun WrapSend(
} else {
Send(
balanceState = balanceState,
isHideBalances = isHideBalances,
sendStage = sendStage,
onCreateZecSend = { newZecSend ->
viewModel.onCreateZecSendClick(
@ -299,6 +283,8 @@ internal fun WrapSend(
)
},
onBack = onBackAction,
onQrScannerOpen = goToQrScanner,
hasCameraFeature = hasCameraFeature,
recipientAddressState = recipientAddressState,
onRecipientAddressChange = {
scope.launch {
@ -312,14 +298,11 @@ internal fun WrapSend(
)
}
},
memoState = memoState,
setMemoState = setMemoState,
amountState = amountState,
setAmountState = setAmountState,
onQrScannerOpen = goToQrScanner,
goBalances = goBalances,
hasCameraFeature = hasCameraFeature,
walletSnapshot = walletSnapshot,
amountState = amountState,
setMemoState = setMemoState,
memoState = memoState,
selectedAccount = selectedAccount,
exchangeRateState = exchangeRateState,
sendAddressBookState = sendAddressBookState,
zashiMainTopAppBarState = zashiMainTopAppBarState

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.ui.screen.send
import cash.z.ecc.sdk.model.AddressType
import kotlinx.serialization.Serializable
@Serializable
data class Send(
val recipientAddress: String? = null,
val recipientAddressType: AddressType? = null,
)

View File

@ -6,7 +6,6 @@ import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.usecase.CreateProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateZip321ProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase
@ -34,7 +33,6 @@ class SendViewModel(
private val observeContactByAddress: ObserveContactByAddressUseCase,
private val observeContactPicked: ObserveContactPickedUseCase,
private val createProposal: CreateProposalUseCase,
private val createKeystoneZip321TransactionProposal: CreateZip321ProposalUseCase,
private val observeWalletAccounts: ObserveWalletAccountsUseCase,
private val navigateToAddressBook: NavigateToAddressBookUseCase
) : ViewModel() {
@ -142,17 +140,4 @@ class SendViewModel(
Twig.error(e) { "Error creating proposal" }
}
}
@Suppress("TooGenericExceptionCaught")
fun onCreateZecSend321Click(
zip321Uri: String,
setSendStage: (SendStage) -> Unit,
) = viewModelScope.launch {
try {
createKeystoneZip321TransactionProposal(zip321Uri)
} catch (e: Exception) {
setSendStage(SendStage.SendFailure(e.cause?.message ?: e.message ?: ""))
Twig.error(e) { "Error creating proposal" }
}
}
}

View File

@ -1,7 +0,0 @@
package co.electriccoin.zcash.ui.screen.send.model
data class SendArguments(
val recipientAddress: RecipientAddressState? = null,
val zip321Uri: String? = null,
val clearForm: Boolean = false,
)

View File

@ -57,27 +57,25 @@ 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.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.canSpend
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.appbar.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppbar
import co.electriccoin.zcash.ui.common.model.WalletAccount
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.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.ZashiTextFieldDefaults
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.scaffoldPadding
import co.electriccoin.zcash.ui.fixture.BalanceStateFixture
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.balances.BalanceState
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
import co.electriccoin.zcash.ui.screen.send.model.MemoState
@ -92,11 +90,11 @@ import java.util.Locale
private fun PreviewSendForm() {
ZcashTheme(forceDarkMode = false) {
Send(
balanceState = BalanceStateFixture.new(),
sendStage = SendStage.Form,
onCreateZecSend = {},
onBack = {},
onQrScannerOpen = {},
goBalances = {},
hasCameraFeature = true,
recipientAddressState = RecipientAddressState("invalid_address", AddressType.Invalid()),
onRecipientAddressChange = {},
@ -109,9 +107,7 @@ private fun PreviewSendForm() {
),
setMemoState = {},
memoState = MemoState.new("Test message "),
walletSnapshot = WalletSnapshotFixture.new(),
balanceState = BalanceStateFixture.new(),
isHideBalances = false,
selectedAccount = null,
exchangeRateState = ExchangeRateState.OptedOut,
sendAddressBookState =
SendAddressBookState(
@ -129,11 +125,11 @@ private fun PreviewSendForm() {
private fun SendFormTransparentAddressPreview() {
ZcashTheme(forceDarkMode = false) {
Send(
balanceState = BalanceStateFixture.new(),
sendStage = SendStage.Form,
onCreateZecSend = {},
onBack = {},
onQrScannerOpen = {},
goBalances = {},
hasCameraFeature = true,
recipientAddressState =
RecipientAddressState(
@ -150,9 +146,7 @@ private fun SendFormTransparentAddressPreview() {
),
setMemoState = {},
memoState = MemoState.new("Test message"),
walletSnapshot = WalletSnapshotFixture.new(),
balanceState = BalanceStateFixture.new(),
isHideBalances = false,
selectedAccount = null,
exchangeRateState = ExchangeRateState.OptedOut,
sendAddressBookState =
SendAddressBookState(
@ -171,12 +165,10 @@ private fun SendFormTransparentAddressPreview() {
@Composable
fun Send(
balanceState: BalanceState,
isHideBalances: Boolean,
sendStage: SendStage,
onCreateZecSend: (ZecSend) -> Unit,
onBack: () -> Unit,
onQrScannerOpen: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean,
recipientAddressState: RecipientAddressState,
onRecipientAddressChange: (String) -> Unit,
@ -184,40 +176,39 @@ fun Send(
amountState: AmountState,
setMemoState: (MemoState) -> Unit,
memoState: MemoState,
walletSnapshot: WalletSnapshot,
selectedAccount: WalletAccount?,
exchangeRateState: ExchangeRateState,
sendAddressBookState: SendAddressBookState,
zashiMainTopAppBarState: ZashiMainTopAppBarState?,
) {
if (selectedAccount == null) {
return
}
BlankBgScaffold(topBar = {
ZashiMainTopAppBar(zashiMainTopAppBarState)
ZashiTopAppbar(
title = null,
state = zashiMainTopAppBarState,
onBack = onBack
)
}) { paddingValues ->
SendMainContent(
balanceState = balanceState,
isHideBalances = isHideBalances,
walletSnapshot = walletSnapshot,
selectedAccount = selectedAccount,
exchangeRateState = exchangeRateState,
onBack = onBack,
sendStage = sendStage,
onCreateZecSend = onCreateZecSend,
sendStage = sendStage,
onQrScannerOpen = onQrScannerOpen,
recipientAddressState = recipientAddressState,
onRecipientAddressChange = onRecipientAddressChange,
hasCameraFeature = hasCameraFeature,
amountState = amountState,
setAmountState = setAmountState,
memoState = memoState,
setMemoState = setMemoState,
onQrScannerOpen = onQrScannerOpen,
goBalances = goBalances,
hasCameraFeature = hasCameraFeature,
modifier =
Modifier
.padding(
top = paddingValues.calculateTopPadding() + ZashiDimensions.Spacing.spacingLg,
bottom = ZashiDimensions.Spacing.spacing4xl,
start = ZashiDimensions.Spacing.spacing3xl,
end = ZashiDimensions.Spacing.spacing3xl
),
exchangeRateState = exchangeRateState,
sendState = sendAddressBookState
sendState = sendAddressBookState,
modifier = Modifier.scaffoldPadding(paddingValues)
)
}
}
@ -226,11 +217,9 @@ fun Send(
@Composable
private fun SendMainContent(
balanceState: BalanceState,
isHideBalances: Boolean,
walletSnapshot: WalletSnapshot,
selectedAccount: WalletAccount,
exchangeRateState: ExchangeRateState,
onBack: () -> Unit,
goBalances: () -> Unit,
onCreateZecSend: (ZecSend) -> Unit,
sendStage: SendStage,
onQrScannerOpen: () -> Unit,
@ -249,9 +238,9 @@ private fun SendMainContent(
SendForm(
balanceState = balanceState,
isHideBalances = isHideBalances,
walletSnapshot = walletSnapshot,
selectedAccount = selectedAccount,
recipientAddressState = recipientAddressState,
exchangeRateState = exchangeRateState,
onRecipientAddressChange = onRecipientAddressChange,
amountState = amountState,
setAmountState = setAmountState,
@ -259,11 +248,9 @@ private fun SendMainContent(
setMemoState = setMemoState,
onCreateZecSend = onCreateZecSend,
onQrScannerOpen = onQrScannerOpen,
goBalances = goBalances,
hasCameraFeature = hasCameraFeature,
modifier = modifier,
exchangeRateState = exchangeRateState,
sendState = sendState
sendState = sendState,
modifier = modifier
)
if (sendStage is SendStage.SendFailure) {
@ -284,8 +271,7 @@ private fun SendMainContent(
@Composable
private fun SendForm(
balanceState: BalanceState,
isHideBalances: Boolean,
walletSnapshot: WalletSnapshot,
selectedAccount: WalletAccount,
recipientAddressState: RecipientAddressState,
exchangeRateState: ExchangeRateState,
onRecipientAddressChange: (String) -> Unit,
@ -295,7 +281,6 @@ private fun SendForm(
setMemoState: (MemoState) -> Unit,
onCreateZecSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean,
sendState: SendAddressBookState,
modifier: Modifier = Modifier,
@ -313,10 +298,7 @@ private fun SendForm(
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BalanceWidget(
balanceState = balanceState,
isHideBalances = isHideBalances,
isReferenceToBalances = true,
onReferenceClick = goBalances
balanceState = balanceState
)
Spacer(modifier = Modifier.height(24.dp))
@ -364,7 +346,7 @@ private fun SendForm(
} ?: false,
monetarySeparators = monetarySeparators,
setAmountState = setAmountState,
walletSnapshot = walletSnapshot,
selectedAccount = selectedAccount,
exchangeRateState = exchangeRateState
)
@ -388,7 +370,7 @@ private fun SendForm(
memoState = memoState,
onCreateZecSend = onCreateZecSend,
recipientAddressState = recipientAddressState,
walletSnapshot = walletSnapshot,
selectedAccount = selectedAccount,
)
}
}
@ -400,7 +382,7 @@ fun SendButton(
memoState: MemoState,
onCreateZecSend: (ZecSend) -> Unit,
recipientAddressState: RecipientAddressState,
walletSnapshot: WalletSnapshot,
selectedAccount: WalletAccount,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@ -411,7 +393,7 @@ fun SendButton(
recipientAddressState.address.isNotEmpty() &&
amountState is AmountState.Valid &&
amountState.value.isNotBlank() &&
walletSnapshot.canSpend(amountState.zatoshi) &&
selectedAccount.canSpend(amountState.zatoshi) &&
// A valid memo is necessary only for non-transparent recipient
(recipientAddressState.type == AddressType.Transparent || memoState is MemoState.Correct)
@ -602,7 +584,7 @@ fun SendFormAmountTextField(
monetarySeparators: MonetarySeparators,
exchangeRateState: ExchangeRateState,
setAmountState: (AmountState) -> Unit,
walletSnapshot: WalletSnapshot,
selectedAccount: WalletAccount,
) {
val focusManager = LocalFocusManager.current
@ -621,7 +603,7 @@ fun SendFormAmountTextField(
}
is AmountState.Valid -> {
if (walletSnapshot.spendableBalance() < amountState.zatoshi) {
if (selectedAccount.spendableBalance < amountState.zatoshi) {
stringResource(id = R.string.send_amount_insufficient_balance)
} else {
null

View File

@ -8,7 +8,6 @@ import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW
import co.electriccoin.zcash.ui.R
@ -27,6 +26,7 @@ import co.electriccoin.zcash.ui.design.component.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs
import co.electriccoin.zcash.ui.screen.integrations.Integrations
import co.electriccoin.zcash.ui.screen.settings.model.SettingsState
import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState
import co.electriccoin.zcash.ui.screen.settings.model.TroubleshootingItemState
@ -223,7 +223,7 @@ class SettingsViewModel(
fun onBack() = navigationRouter.back()
private fun onIntegrationsClick() = navigationRouter.forward(INTEGRATIONS)
private fun onIntegrationsClick() = navigationRouter.forward(Integrations)
private fun onAdvancedSettingsClick() = navigationRouter.forward(ADVANCED_SETTINGS)

View File

@ -6,13 +6,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun AndroidTaxExport() {
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val walletSnapshot = walletViewModel.currentWalletSnapshot.collectAsStateWithLifecycle().value
val viewModel = koinViewModel<TaxExportViewModel> { parametersOf(walletSnapshot?.isZashi) }
val viewModel = koinViewModel<TaxExportViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle()
val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()

View File

@ -1,9 +1,10 @@
package co.electriccoin.zcash.ui.screen.taxexport
import co.electriccoin.zcash.ui.design.component.ButtonState
import co.electriccoin.zcash.ui.design.util.StringResource
data class TaxExportState(
val text: StringResource,
val exportButton: ButtonState,
val isZashiAccount: Boolean,
val onBack: () -> Unit,
)

View File

@ -24,6 +24,7 @@ import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreenSizes
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes
@ -86,15 +87,7 @@ private fun Content(
Spacer(Modifier.height(12.dp))
Text(
text =
stringResource(
R.string.tax_export_message,
if (state.isZashiAccount) {
stringResource(R.string.zashi_wallet_name)
} else {
stringResource(R.string.keystone_wallet_name)
}
),
text = state.text.getValue(),
style = ZashiTypography.textSm,
color = ZashiColors.Text.textPrimary
)
@ -121,7 +114,11 @@ private fun ExportPrivateDataPreview() =
text = stringRes(R.string.tax_export_export_button),
onClick = {}
),
isZashiAccount = true,
text =
stringRes(
R.string.tax_export_message,
stringResource(R.string.zashi_wallet_name)
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)

Some files were not shown because too many files have changed in this diff Show More