Merge pull request #1809 from Electric-Coin-Company/feature/home-redesign

Home redesign
This commit is contained in:
Milan 2025-03-24 09:59:45 +01:00 committed by GitHub
commit bea5ac6c78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
124 changed files with 2118 additions and 4417 deletions

View File

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

View File

@ -0,0 +1,71 @@
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,
shape = RoundedCornerShape(16.dp),
color = ZashiColors.Surfaces.bgSecondary
) {
Column(
modifier =
Modifier
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = state.onClick,
role = Role.Button,
)
.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

@ -1,20 +0,0 @@
package co.electriccoin.zcash.ui.fixture
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
internal object SendArgumentsWrapperFixture {
val RECIPIENT_ADDRESS =
SerializableAddress(
address = WalletFixture.Alice.getAddresses(ZcashNetwork.Testnet).unified,
type = AddressType.Unified
)
fun new(recipientAddress: SerializableAddress? = RECIPIENT_ADDRESS) =
SendArguments(
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, isLoading = false,
), ),
zashiMainTopAppBarState = appBarState =
ZashiMainTopAppBarStateFixture.new( ZashiMainTopAppBarStateFixture.new(
settingsButton = settingsButton =
IconButtonState( IconButtonState(

View File

@ -114,11 +114,6 @@ class SendViewTestSetup(
onQrScannerOpen = { onQrScannerOpen = {
onScannerCount.incrementAndGet() 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, hasCameraFeature = hasCameraFeature,
recipientAddressState = RecipientAddressState("", AddressType.Invalid()), recipientAddressState = RecipientAddressState("", AddressType.Invalid()),
onRecipientAddressChange = { onRecipientAddressChange = {
@ -137,7 +132,7 @@ class SendViewTestSetup(
), ),
setMemoState = {}, setMemoState = {},
memoState = MemoState.new(""), memoState = MemoState.new(""),
walletSnapshot = selectedAccount =
WalletSnapshotFixture.new( WalletSnapshotFixture.new(
saplingBalance = saplingBalance =
WalletBalanceFixture.new( WalletBalanceFixture.new(

View File

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

View File

@ -9,15 +9,16 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import cash.z.ecc.android.sdk.ext.collectWith import cash.z.ecc.android.sdk.ext.collectWith
import cash.z.ecc.android.sdk.fixture.WalletAddressFixture import cash.z.ecc.android.sdk.fixture.WalletAddressFixture
import cash.z.ecc.android.sdk.fixture.WalletFixture
import cash.z.ecc.android.sdk.model.Memo import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.model.ZecSend import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.sdk.fixture.ZecRequestFixture import cash.z.ecc.sdk.fixture.ZecRequestFixture
import cash.z.ecc.sdk.fixture.ZecSendFixture import cash.z.ecc.sdk.fixture.ZecSendFixture
import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.fixture.SendArgumentsWrapperFixture
import co.electriccoin.zcash.ui.screen.send.SendTag import co.electriccoin.zcash.ui.screen.send.SendTag
import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup
import co.electriccoin.zcash.ui.screen.send.assertOnForm import co.electriccoin.zcash.ui.screen.send.assertOnForm
@ -379,7 +380,7 @@ class SendViewTest : UiTestPrerequisites() {
composeTestRule.onNodeWithText(getStringResource(R.string.send_address_hint)).also { composeTestRule.onNodeWithText(getStringResource(R.string.send_address_hint)).also {
it.assertTextEquals( it.assertTextEquals(
getStringResource(R.string.send_address_hint), getStringResource(R.string.send_address_hint),
SendArgumentsWrapperFixture.RECIPIENT_ADDRESS.address, WalletFixture.Alice.getAddresses(ZcashNetwork.Testnet).unified,
includeEditableText = true includeEditableText = true
) )
} }

View File

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

View File

@ -1,7 +1,5 @@
package co.electriccoin.zcash.di 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.BiometricRepository
import co.electriccoin.zcash.ui.common.repository.BiometricRepositoryImpl import co.electriccoin.zcash.ui.common.repository.BiometricRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository
@ -29,7 +27,6 @@ val repositoryModule =
singleOf(::WalletRepositoryImpl) bind WalletRepository::class singleOf(::WalletRepositoryImpl) bind WalletRepository::class
singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class
singleOf(::ExchangeRateRepositoryImpl) bind ExchangeRateRepository::class singleOf(::ExchangeRateRepositoryImpl) bind ExchangeRateRepository::class
singleOf(::BalanceRepositoryImpl) bind BalanceRepository::class
singleOf(::FlexaRepositoryImpl) bind FlexaRepository::class singleOf(::FlexaRepositoryImpl) bind FlexaRepository::class
singleOf(::BiometricRepositoryImpl) bind BiometricRepository::class singleOf(::BiometricRepositoryImpl) bind BiometricRepository::class
singleOf(::KeystoneProposalRepositoryImpl) bind KeystoneProposalRepository::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.CancelProposalFlowUseCase
import co.electriccoin.zcash.ui.common.usecase.ConfirmProposalUseCase import co.electriccoin.zcash.ui.common.usecase.ConfirmProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CopyToClipboardUseCase 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.CreateKeystoneAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneProposalPCZTEncoderUseCase import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneProposalPCZTEncoderUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneShieldProposalUseCase import co.electriccoin.zcash.ui.common.usecase.CreateKeystoneShieldProposalUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateOrUpdateTransactionNoteUseCase import co.electriccoin.zcash.ui.common.usecase.CreateOrUpdateTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.CreateProposalUseCase 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.DeleteContactUseCase
import co.electriccoin.zcash.ui.common.usecase.DeleteTransactionNoteUseCase import co.electriccoin.zcash.ui.common.usecase.DeleteTransactionNoteUseCase
import co.electriccoin.zcash.ui.common.usecase.DeriveKeystoneAccountUnifiedAddressUseCase import co.electriccoin.zcash.ui.common.usecase.DeriveKeystoneAccountUnifiedAddressUseCase
@ -33,10 +33,12 @@ import co.electriccoin.zcash.ui.common.usecase.GetTransactionFiltersUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransactionMetadataUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase 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.GetZashiAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.GetZashiSpendingKeyUseCase import co.electriccoin.zcash.ui.common.usecase.GetZashiSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase
import co.electriccoin.zcash.ui.common.usecase.MarkTxMemoAsReadUseCase import co.electriccoin.zcash.ui.common.usecase.MarkTxMemoAsReadUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
@ -55,8 +57,9 @@ import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedWalletAccountUseCa
import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveTransactionSubmitStateUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveTransactionSubmitStateUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase 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.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.ParseKeystonePCZTUseCase
import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneSignInRequestUseCase import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneSignInRequestUseCase
import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneUrToZashiAccountsUseCase import co.electriccoin.zcash.ui.common.usecase.ParseKeystoneUrToZashiAccountsUseCase
@ -114,7 +117,7 @@ val useCaseModule =
factoryOf(::ShareImageUseCase) factoryOf(::ShareImageUseCase)
factoryOf(::Zip321BuildUriUseCase) factoryOf(::Zip321BuildUriUseCase)
factoryOf(::Zip321ParseUriValidationUseCase) factoryOf(::Zip321ParseUriValidationUseCase)
factoryOf(::ObserveWalletStateUseCase) factoryOf(::GetWalletStateInformationUseCase)
factoryOf(::IsCoinbaseAvailableUseCase) factoryOf(::IsCoinbaseAvailableUseCase)
factoryOf(::GetZashiSpendingKeyUseCase) factoryOf(::GetZashiSpendingKeyUseCase)
factoryOf(::ObservePersistableWalletUseCase) factoryOf(::ObservePersistableWalletUseCase)
@ -138,7 +141,8 @@ val useCaseModule =
factoryOf(::GetCurrentTransactionsUseCase) factoryOf(::GetCurrentTransactionsUseCase)
factoryOf(::GetCurrentFilteredTransactionsUseCase) onClose ::closeableCallback factoryOf(::GetCurrentFilteredTransactionsUseCase) onClose ::closeableCallback
factoryOf(::CreateProposalUseCase) factoryOf(::CreateProposalUseCase)
factoryOf(::CreateZip321ProposalUseCase) factoryOf(::OnZip321ScannedUseCase)
factoryOf(::OnAddressScannedUseCase)
factoryOf(::CreateKeystoneShieldProposalUseCase) factoryOf(::CreateKeystoneShieldProposalUseCase)
factoryOf(::ParseKeystonePCZTUseCase) factoryOf(::ParseKeystonePCZTUseCase)
factoryOf(::ParseKeystoneSignInRequestUseCase) factoryOf(::ParseKeystoneSignInRequestUseCase)
@ -172,4 +176,6 @@ val useCaseModule =
factoryOf(::GetMetadataUseCase) factoryOf(::GetMetadataUseCase)
factoryOf(::ExportTaxUseCase) factoryOf(::ExportTaxUseCase)
factoryOf(::NavigateToTaxExportUseCase) factoryOf(::NavigateToTaxExportUseCase)
factoryOf(::CreateFlexaTransactionUseCase)
factoryOf(::IsRestoreSuccessDialogVisibleUseCase)
} }

View File

@ -1,17 +1,20 @@
package co.electriccoin.zcash.di 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.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.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
import co.electriccoin.zcash.ui.screen.accountlist.viewmodel.AccountListViewModel 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.AddressBookViewModel
import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.SelectRecipientViewModel import co.electriccoin.zcash.ui.screen.addressbook.viewmodel.SelectRecipientViewModel
import co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel.AdvancedSettingsViewModel 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.chooseserver.ChooseServerViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
import co.electriccoin.zcash.ui.screen.feedback.viewmodel.FeedbackViewModel 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.integrations.viewmodel.IntegrationsViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel 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.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransactionViewModel 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.scan.viewmodel.ScanViewModel
import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystonePCZTViewModel import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystonePCZTViewModel
import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystoneSignInRequestViewModel import co.electriccoin.zcash.ui.screen.scankeystone.viewmodel.ScanKeystoneSignInRequestViewModel
@ -53,7 +56,7 @@ val viewModelModule =
module { module {
viewModelOf(::WalletViewModel) viewModelOf(::WalletViewModel)
viewModelOf(::AuthenticationViewModel) viewModelOf(::AuthenticationViewModel)
viewModelOf(::HomeViewModel) viewModelOf(::OldHomeViewModel)
viewModelOf(::OnboardingViewModel) viewModelOf(::OnboardingViewModel)
viewModelOf(::StorageCheckViewModel) viewModelOf(::StorageCheckViewModel)
viewModelOf(::RestoreViewModel) viewModelOf(::RestoreViewModel)
@ -78,17 +81,30 @@ val viewModelModule =
viewModelOf(::ReceiveViewModel) viewModelOf(::ReceiveViewModel)
viewModelOf(::QrCodeViewModel) viewModelOf(::QrCodeViewModel)
viewModelOf(::RequestViewModel) viewModelOf(::RequestViewModel)
viewModelOf(::IntegrationsViewModel) viewModel { (args: Scan) ->
viewModel { (args: ScanNavigationArgs) ->
ScanViewModel( ScanViewModel(
args = args, args = args,
getSynchronizer = get(), getSynchronizer = get(),
zip321ParseUriValidationUseCase = get(), zip321ParseUriValidationUseCase = get(),
onAddressScanned = get(),
zip321Scanned = get()
) )
} }
viewModelOf(::ScanKeystoneSignInRequestViewModel) viewModelOf(::ScanKeystoneSignInRequestViewModel)
viewModelOf(::ScanKeystonePCZTViewModel) 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) viewModelOf(::SendViewModel)
viewModel { (args: SeedNavigationArgs) -> viewModel { (args: SeedNavigationArgs) ->
SeedViewModel( SeedViewModel(
@ -100,7 +116,7 @@ val viewModelModule =
viewModelOf(::FeedbackViewModel) viewModelOf(::FeedbackViewModel)
viewModelOf(::SignKeystoneTransactionViewModel) viewModelOf(::SignKeystoneTransactionViewModel)
viewModelOf(::AccountListViewModel) viewModelOf(::AccountListViewModel)
viewModelOf(::ZashiMainTopAppBarViewModel) viewModelOf(::ZashiTopAppBarViewModel)
viewModel { (args: SelectKeystoneAccount) -> viewModel { (args: SelectKeystoneAccount) ->
SelectKeystoneAccountViewModel( SelectKeystoneAccountViewModel(
args = args, args = args,
@ -138,4 +154,6 @@ val viewModelModule =
) )
} }
viewModelOf(::TaxExportViewModel) 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.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationUIState import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationUIState
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel 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.SecretState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.configuration.RemoteConfig import co.electriccoin.zcash.ui.configuration.RemoteConfig
@ -67,7 +67,7 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
private val homeViewModel by viewModel<HomeViewModel>() private val oldHomeViewModel by viewModel<OldHomeViewModel>()
val walletViewModel by viewModel<WalletViewModel>() 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() // 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() enableEdgeToEdge()
setContentCompat { setContentCompat {
Override(configurationOverrideFlow) { Override(configurationOverrideFlow) {
val isHideBalances by homeViewModel.isHideBalances.collectAsStateWithLifecycle() val isHideBalances by oldHomeViewModel.isHideBalances.collectAsStateWithLifecycle()
ZcashTheme( ZcashTheme(
balancesAvailable = isHideBalances == false balancesAvailable = isHideBalances == false
) { ) {
@ -235,7 +235,7 @@ class MainActivity : FragmentActivity() {
@Composable @Composable
private fun MainContent() { private fun MainContent() {
val configuration = homeViewModel.configurationFlow.collectAsStateWithLifecycle().value val configuration = oldHomeViewModel.configurationFlow.collectAsStateWithLifecycle().value
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
// Note this condition needs to be kept in sync with the condition in setupSplashScreen() // Note this condition needs to be kept in sync with the condition in setupSplashScreen()
@ -295,7 +295,7 @@ class MainActivity : FragmentActivity() {
val isEnableBackgroundSyncFlow = val isEnableBackgroundSyncFlow =
run { run {
val isSecretReadyFlow = walletViewModel.secretState.map { it is SecretState.Ready } val isSecretReadyFlow = walletViewModel.secretState.map { it is SecretState.Ready }
val isBackgroundSyncEnabledFlow = homeViewModel.isBackgroundSyncEnabled.filterNotNull() val isBackgroundSyncEnabledFlow = oldHomeViewModel.isBackgroundSyncEnabled.filterNotNull()
isSecretReadyFlow.combine(isBackgroundSyncEnabledFlow) { isSecretReady, isBackgroundSyncEnabled -> isSecretReadyFlow.combine(isBackgroundSyncEnabledFlow) { isSecretReady, isBackgroundSyncEnabled ->
isSecretReady && isBackgroundSyncEnabled isSecretReady && isBackgroundSyncEnabled

View File

@ -4,12 +4,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType 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.Twig
import co.electriccoin.zcash.spackle.getSerializableCompat import co.electriccoin.zcash.spackle.getSerializableCompat
import co.electriccoin.zcash.ui.NavigationArgs.ADDRESS_TYPE 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.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
import co.electriccoin.zcash.ui.NavigationTargets.DELETE_WALLET import co.electriccoin.zcash.ui.NavigationTargets.DELETE_WALLET
import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN 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.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.NOT_ENOUGH_SPACE
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST 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.SUPPORT
import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW
import co.electriccoin.zcash.ui.common.compose.LocalNavController 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.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.provider.isInForeground 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.enterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.exitTransition 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.popEnterTransition
import co.electriccoin.zcash.ui.design.animation.ScreenAnimation.popExitTransition 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.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.AccountList
import co.electriccoin.zcash.ui.screen.accountlist.AndroidAccountList import co.electriccoin.zcash.ui.screen.accountlist.AndroidAccountList
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs 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.exchangerate.settings.AndroidSettingsExchangeRateOptIn
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.feedback.WrapFeedback import co.electriccoin.zcash.ui.screen.feedback.WrapFeedback
import co.electriccoin.zcash.ui.screen.home.WrapHome import co.electriccoin.zcash.ui.screen.flexa.FlexaViewModel
import co.electriccoin.zcash.ui.screen.integrations.WrapIntegrations 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.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.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest import co.electriccoin.zcash.ui.screen.request.WrapRequest
import co.electriccoin.zcash.ui.screen.reviewtransaction.AndroidReviewTransaction import co.electriccoin.zcash.ui.screen.reviewtransaction.AndroidReviewTransaction
import co.electriccoin.zcash.ui.screen.reviewtransaction.ReviewTransaction 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.scan.WrapScanValidator
import co.electriccoin.zcash.ui.screen.scankeystone.ScanKeystonePCZTRequest import co.electriccoin.zcash.ui.screen.scankeystone.ScanKeystonePCZTRequest
import co.electriccoin.zcash.ui.screen.scankeystone.ScanKeystoneSignInRequest 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.seed.WrapSeed
import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.AndroidSelectKeystoneAccount import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.AndroidSelectKeystoneAccount
import co.electriccoin.zcash.ui.screen.selectkeystoneaccount.SelectKeystoneAccount 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.settings.WrapSettings
import co.electriccoin.zcash.ui.screen.signkeystonetransaction.AndroidSignKeystoneTransaction import co.electriccoin.zcash.ui.screen.signkeystonetransaction.AndroidSignKeystoneTransaction
import co.electriccoin.zcash.ui.screen.signkeystonetransaction.SignKeystoneTransaction 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.StateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
// TODO [#1297]: Consider: Navigation passing complex data arguments different way // TODO [#1297]: Consider: Navigation passing complex data arguments different way
@ -120,6 +120,8 @@ import org.koin.compose.koinInject
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
internal fun MainActivity.Navigation() { internal fun MainActivity.Navigation() {
val navController = LocalNavController.current val navController = LocalNavController.current
val flexaViewModel = koinViewModel<FlexaViewModel>()
val navigationRouter = koinInject<NavigationRouter>()
// Helper properties for triggering the system security UI from callbacks // Helper properties for triggering the system security UI from callbacks
val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) = val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) =
@ -128,69 +130,25 @@ internal fun MainActivity.Navigation() {
rememberSaveable { mutableStateOf(false) } rememberSaveable { mutableStateOf(false) }
val (deleteWalletAuthentication, setDeleteWalletAuthentication) = val (deleteWalletAuthentication, setDeleteWalletAuthentication) =
rememberSaveable { mutableStateOf(false) } rememberSaveable { mutableStateOf(false) }
val navigationRouter = koinInject<NavigationRouter>()
val navigator: Navigator = remember { NavigatorImpl(this@Navigation, navController, flexaViewModel) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navigationRouter.observe().collect { navigationRouter.observePipeline().collect {
when (it) { navigator.executeCommand(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
)
}
} }
} }
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = HOME, startDestination = Home,
enterTransition = { enterTransition() }, enterTransition = { enterTransition() },
exitTransition = { exitTransition() }, exitTransition = { exitTransition() },
popEnterTransition = { popEnterTransition() }, popEnterTransition = { popEnterTransition() },
popExitTransition = { popExitTransition() } popExitTransition = { popExitTransition() }
) { ) {
composable(HOME) { backStack -> composable<Home> {
NavigationHome(navController, backStack) NavigationHome(navController)
} }
composable(SETTINGS) { composable(SETTINGS) {
WrapSettings() WrapSettings()
@ -287,8 +245,11 @@ internal fun MainActivity.Navigation() {
composable(WHATS_NEW) { composable(WHATS_NEW) {
WrapWhatsNew() WrapWhatsNew()
} }
composable(INTEGRATIONS) { composable<Integrations> {
WrapIntegrations() AndroidIntegrations()
}
dialog<DialogIntegrations> {
AndroidDialogIntegrations()
} }
composable(EXCHANGE_RATE_OPT_IN) { composable(EXCHANGE_RATE_OPT_IN) {
AndroidExchangeRateOptIn() AndroidExchangeRateOptIn()
@ -315,18 +276,18 @@ internal fun MainActivity.Navigation() {
AndroidAccountList() AndroidAccountList()
} }
composable( composable(
route = ScanNavigationArgs.ROUTE, route = Scan.ROUTE,
arguments = arguments =
listOf( listOf(
navArgument(ScanNavigationArgs.KEY) { navArgument(Scan.KEY) {
type = NavType.EnumType(ScanNavigationArgs::class.java) type = NavType.EnumType(Scan::class.java)
defaultValue = ScanNavigationArgs.DEFAULT defaultValue = Scan.SEND
} }
) )
) { backStackEntry -> ) { backStackEntry ->
val mode = val mode =
backStackEntry.arguments backStackEntry.arguments
?.getSerializableCompat<ScanNavigationArgs>(ScanNavigationArgs.KEY) ?: ScanNavigationArgs.DEFAULT ?.getSerializableCompat<Scan>(Scan.KEY) ?: Scan.SEND
WrapScanValidator(args = mode) WrapScanValidator(args = mode)
} }
@ -438,6 +399,12 @@ internal fun MainActivity.Navigation() {
composable<TaxExport> { composable<TaxExport> {
AndroidTaxExport() 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. * This is the Home screens sub-navigation. We could consider creating a separate sub-navigation graph.
*/ */
@Composable @Composable
private fun MainActivity.NavigationHome( private fun MainActivity.NavigationHome(navController: NavHostController) {
navController: NavHostController,
backStack: NavBackStackEntry
) {
val applicationStateProvider: ApplicationStateProvider by inject() val applicationStateProvider: ApplicationStateProvider by inject()
WrapHome( AndroidHome()
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)
},
)
val isEnoughSpace by storageCheckViewModel.isEnoughSpace.collectAsStateWithLifecycle() 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 * 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. * 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() 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 { object NavigationTargets {
const val ABOUT = "about" const val ABOUT = "about"
const val ADVANCED_SETTINGS = "advanced_settings" const val ADVANCED_SETTINGS = "advanced_settings"
const val DELETE_WALLET = "delete_wallet" const val DELETE_WALLET = "delete_wallet"
const val EXCHANGE_RATE_OPT_IN = "exchange_rate_opt_in" const val EXCHANGE_RATE_OPT_IN = "exchange_rate_opt_in"
const val EXPORT_PRIVATE_DATA = "export_private_data" const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HOME = "home"
const val CHOOSE_SERVER = "choose_server" const val CHOOSE_SERVER = "choose_server"
const val INTEGRATIONS = "integrations"
const val NOT_ENOUGH_SPACE = "not_enough_space" const val NOT_ENOUGH_SPACE = "not_enough_space"
const val QR_CODE = "qr_code" const val QR_CODE = "qr_code"
const val REQUEST = "request" const val REQUEST = "request"

View File

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

@ -0,0 +1,5 @@
package co.electriccoin.zcash.ui.common.appbar
object ZashiTopAppBarTags {
const val BACK = "BACK"
}

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.ViewModel
import androidx.lifecycle.viewModelScope 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.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.model.ZashiAccount 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.ObserveSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase
import co.electriccoin.zcash.ui.design.R 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.IconButtonState
import co.electriccoin.zcash.ui.design.component.ZashiMainTopAppBarState
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.accountlist.AccountList 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.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ZashiMainTopAppBarViewModel( class ZashiTopAppBarViewModel(
observeSelectedWalletAccount: ObserveSelectedWalletAccountUseCase, observeSelectedWalletAccount: ObserveSelectedWalletAccountUseCase,
observeWalletState: ObserveWalletStateUseCase, getWalletStateInformation: GetWalletStateInformationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
) : ViewModel() { ) : ViewModel() {
@ -43,7 +41,7 @@ class ZashiMainTopAppBarViewModel(
combine( combine(
observeSelectedWalletAccount.require(), observeSelectedWalletAccount.require(),
isHideBalances, isHideBalances,
observeWalletState() getWalletStateInformation.observe()
) { currentAccount, isHideBalances, walletState -> ) { currentAccount, isHideBalances, walletState ->
createState(currentAccount, isHideBalances, walletState) createState(currentAccount, isHideBalances, walletState)
}.stateIn( }.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.animation.Crossfade
import androidx.compose.foundation.Image 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.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.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.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors 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 import co.electriccoin.zcash.ui.design.util.stringRes
@Composable @Composable
fun ZashiMainTopAppBar( fun ZashiTopAppBarWithAccountSelection(
state: ZashiMainTopAppBarState?, state: ZashiMainTopAppBarState?,
showHideBalances: Boolean = true showHideBalances: Boolean = true
) { ) {
@ -46,7 +49,7 @@ fun ZashiMainTopAppBar(
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top), windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top),
hamburgerMenuActions = { hamburgerMenuActions = {
if (showHideBalances) { if (showHideBalances) {
Crossfade(state.balanceVisibilityButton, label = "") { Crossfade(state.balanceVisibilityButton, label = "BalanceVisibility") {
ZashiIconButton(it, modifier = Modifier.size(40.dp)) ZashiIconButton(it, modifier = Modifier.size(40.dp))
} }
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
@ -144,7 +147,7 @@ data class AccountSwitchState(
@Composable @Composable
private fun ZashiMainTopAppBarPreview() = private fun ZashiMainTopAppBarPreview() =
ZcashTheme { ZcashTheme {
ZashiMainTopAppBar( ZashiTopAppBarWithAccountSelection(
state = state =
ZashiMainTopAppBarState( ZashiMainTopAppBarState(
accountSwitchState = accountSwitchState =
@ -163,7 +166,7 @@ private fun ZashiMainTopAppBarPreview() =
@Composable @Composable
private fun KeystoneMainTopAppBarPreview() = private fun KeystoneMainTopAppBarPreview() =
ZcashTheme { ZcashTheme {
ZashiMainTopAppBar( ZashiTopAppBarWithAccountSelection(
state = state =
ZashiMainTopAppBarState( ZashiMainTopAppBarState(
accountSwitchState = accountSwitchState =
@ -182,7 +185,7 @@ private fun KeystoneMainTopAppBarPreview() =
@Composable @Composable
private fun MainTopAppBarWithSubtitlePreview() = private fun MainTopAppBarWithSubtitlePreview() =
ZcashTheme { ZcashTheme {
ZashiMainTopAppBar( ZashiTopAppBarWithAccountSelection(
state = state =
ZashiMainTopAppBarState( ZashiMainTopAppBarState(
accountSwitchState = accountSwitchState =

View File

@ -0,0 +1,50 @@
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.platform.testTag
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,
modifier = Modifier.testTag(ZashiTopAppBarTags.BACK)
)
},
regularActions = {
if (state?.balanceVisibilityButton != null && showHideBalances) {
Crossfade(state.balanceVisibilityButton, label = "BalanceVisibility") {
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.SubmitResult
import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider 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.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -92,19 +91,21 @@ class ProposalDataSourceImpl(
zip321Uri: String zip321Uri: String
): Zip321TransactionProposal = ): Zip321TransactionProposal =
withContext(Dispatchers.IO) { 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 { 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( Zip321TransactionProposal(
destination = destination =
synchronizer synchronizer
@ -276,3 +277,5 @@ data class Zip321TransactionProposal(
override val memo: Memo, override val memo: Memo,
override val proposal: Proposal override val proposal: Proposal
) : SendTransactionProposal ) : SendTransactionProposal
private const val DEFAULT_SHIELDING_THRESHOLD = 100000L

View File

@ -1,36 +0,0 @@
package co.electriccoin.zcash.ui.common.model
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.common.extension.AddressTypeAsStringSerializer
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
@Serializable
data class SerializableAddress(
val address: String,
@Serializable(with = AddressTypeAsStringSerializer::class)
val type: AddressType
) {
init {
// Basic validation to support the class properties type-safeness
require(address.isNotEmpty()) {
"Address parameter $address can not be empty"
}
}
internal fun toRecipient() = RecipientAddressState(address, type)
// Calling the conversion inside the blocking coroutine is ok, as we do not expect it to be time-consuming
internal fun toWalletAddress() =
runBlocking {
when (type) {
AddressType.Unified -> WalletAddress.Unified.new(address)
AddressType.Shielded -> WalletAddress.Sapling.new(address)
AddressType.Transparent -> WalletAddress.Transparent.new(address)
AddressType.Tex -> WalletAddress.Tex.new(address)
is AddressType.Invalid -> error("Invalid address type")
}
}
}

View File

@ -18,22 +18,7 @@ data class WalletSnapshot(
val transparentBalance: Zatoshi, val transparentBalance: Zatoshi,
val progress: PercentDecimal, val progress: PercentDecimal,
val synchronizerError: SynchronizerError? 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]: WalletSnapshot.canSpend() calculation limitation
// TODO [#1370]: https://github.com/Electric-Coin-Company/zashi-android/issues/1370 // 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.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. // 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 // 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. // to transfer their balance to the latest balance type to make it spendable.
fun WalletSnapshot.spendableBalance() = orchardBalance.available + (saplingBalance?.available ?: Zatoshi(0)) 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,12 +3,15 @@ package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.exception.PcztException import cash.z.ecc.android.sdk.exception.PcztException
import cash.z.ecc.android.sdk.model.Pczt import cash.z.ecc.android.sdk.model.Pczt
import cash.z.ecc.android.sdk.model.ZecSend 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.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.ProposalDataSource import co.electriccoin.zcash.ui.common.datasource.ProposalDataSource
import co.electriccoin.zcash.ui.common.datasource.TransactionProposal import co.electriccoin.zcash.ui.common.datasource.TransactionProposal
import co.electriccoin.zcash.ui.common.datasource.TransactionProposalNotCreatedException 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 co.electriccoin.zcash.ui.common.model.SubmitResult
import com.keystone.sdk.KeystoneSDK import com.keystone.sdk.KeystoneSDK
import com.keystone.sdk.KeystoneZcashSDK
import com.sparrowwallet.hummingbird.UR import com.sparrowwallet.hummingbird.UR
import com.sparrowwallet.hummingbird.UREncoder import com.sparrowwallet.hummingbird.UREncoder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -33,7 +36,7 @@ interface KeystoneProposalRepository {
suspend fun createProposal(zecSend: ZecSend) suspend fun createProposal(zecSend: ZecSend)
@Throws(TransactionProposalNotCreatedException::class) @Throws(TransactionProposalNotCreatedException::class)
suspend fun createZip321Proposal(zip321Uri: String) suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal
@Throws(TransactionProposalNotCreatedException::class) @Throws(TransactionProposalNotCreatedException::class)
suspend fun createShieldProposal() suspend fun createShieldProposal()
@ -80,8 +83,9 @@ class KeystoneProposalRepositoryImpl(
private var proposalPczt: Pczt? = null private var proposalPczt: Pczt? = null
private var pcztWithSignatures: Pczt? = null private var pcztWithSignatures: Pczt? = null
private val keystoneSDK = KeystoneSDK() private val keystoneSDK: KeystoneSDK by lazy { KeystoneSDK() }
private val keystoneZcashSDK = keystoneSDK.zcash private val keystoneZcashSDK: KeystoneZcashSDK
get() = keystoneSDK.zcash
private var pcztWithProofsJob: Job? = null private var pcztWithProofsJob: Job? = null
private var extractPCZTJob: Job? = null private var extractPCZTJob: Job? = null
@ -94,8 +98,8 @@ class KeystoneProposalRepositoryImpl(
} }
} }
override suspend fun createZip321Proposal(zip321Uri: String) { override suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal {
createProposalInternal { return createProposalInternal {
proposalDataSource.createZip321Proposal( proposalDataSource.createZip321Proposal(
account = accountDataSource.getSelectedAccount(), account = accountDataSource.getSelectedAccount(),
zip321Uri = zip321Uri zip321Uri = zip321Uri
@ -213,15 +217,17 @@ class KeystoneProposalRepositoryImpl(
pcztWithSignatures = null pcztWithSignatures = null
} }
private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T) { private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T): T {
val proposal = val proposal =
try { try {
block() block()
} catch (e: TransactionProposalNotCreatedException) { } catch (e: TransactionProposalNotCreatedException) {
Twig.error(e) { "Unable to create proposal" }
transactionProposal.update { null } transactionProposal.update { null }
throw e throw e
} }
transactionProposal.update { proposal } transactionProposal.update { proposal }
return proposal
} }
} }

View File

@ -1,11 +1,13 @@
package co.electriccoin.zcash.ui.common.repository package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.ZecSend 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.AccountDataSource
import co.electriccoin.zcash.ui.common.datasource.ProposalDataSource import co.electriccoin.zcash.ui.common.datasource.ProposalDataSource
import co.electriccoin.zcash.ui.common.datasource.TransactionProposal import co.electriccoin.zcash.ui.common.datasource.TransactionProposal
import co.electriccoin.zcash.ui.common.datasource.TransactionProposalNotCreatedException import co.electriccoin.zcash.ui.common.datasource.TransactionProposalNotCreatedException
import co.electriccoin.zcash.ui.common.datasource.ZashiSpendingKeyDataSource 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 co.electriccoin.zcash.ui.common.model.SubmitResult
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -27,7 +29,7 @@ interface ZashiProposalRepository {
suspend fun createProposal(zecSend: ZecSend) suspend fun createProposal(zecSend: ZecSend)
@Throws(TransactionProposalNotCreatedException::class) @Throws(TransactionProposalNotCreatedException::class)
suspend fun createZip321Proposal(zip321Uri: String) suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal
@Throws(TransactionProposalNotCreatedException::class) @Throws(TransactionProposalNotCreatedException::class)
suspend fun createShieldProposal() suspend fun createShieldProposal()
@ -63,8 +65,8 @@ class ZashiProposalRepositoryImpl(
} }
} }
override suspend fun createZip321Proposal(zip321Uri: String) { override suspend fun createZip321Proposal(zip321Uri: String): Zip321TransactionProposal {
createProposalInternal { return createProposalInternal {
proposalDataSource.createZip321Proposal( proposalDataSource.createZip321Proposal(
account = accountDataSource.getSelectedAccount(), account = accountDataSource.getSelectedAccount(),
zip321Uri = zip321Uri zip321Uri = zip321Uri
@ -127,14 +129,16 @@ class ZashiProposalRepositoryImpl(
submitState.update { null } submitState.update { null }
} }
private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T) { private inline fun <T : TransactionProposal> createProposalInternal(block: () -> T): T {
val proposal = val proposal =
try { try {
block() block()
} catch (e: TransactionProposalNotCreatedException) { } catch (e: TransactionProposalNotCreatedException) {
Twig.error(e) { "Unable to create proposal" }
transactionProposal.update { null } transactionProposal.update { null }
throw e throw e
} }
transactionProposal.update { proposal } 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.NavigationRouter
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.ZashiProposalRepository import co.electriccoin.zcash.ui.common.repository.ZashiProposalRepository
import co.electriccoin.zcash.ui.screen.send.Send
class CancelProposalFlowUseCase( class CancelProposalFlowUseCase(
private val keystoneProposalRepository: KeystoneProposalRepository, private val keystoneProposalRepository: KeystoneProposalRepository,
@ -16,6 +17,6 @@ class CancelProposalFlowUseCase(
if (clearSendForm) { if (clearSendForm) {
observeClearSend.requestClear() 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 package co.electriccoin.zcash.ui.common.usecase
import cash.z.ecc.android.sdk.exception.InitializeException 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.NavigationRouter
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource 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.ZcashAccount
import com.keystone.module.ZcashAccounts import com.keystone.module.ZcashAccounts
class CreateKeystoneAccountUseCase( class CreateKeystoneAccountUseCase(
private val accountDataSource: AccountDataSource, private val accountDataSource: AccountDataSource,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
private val homeTabNavigationRouter: HomeTabNavigationRouter
) { ) {
@Throws(InitializeException.ImportAccountException::class) @Throws(InitializeException.ImportAccountException::class)
suspend operator fun invoke( suspend operator fun invoke(
@ -25,7 +22,6 @@ class CreateKeystoneAccountUseCase(
index = account.index.toLong() index = account.index.toLong()
) )
accountDataSource.selectAccount(createdAccount) accountDataSource.selectAccount(createdAccount)
homeTabNavigationRouter.select(HomeScreenIndex.ACCOUNT)
navigationRouter.backToRoot() 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) { class GetSelectedWalletAccountUseCase(private val accountDataSource: AccountDataSource) {
suspend operator fun invoke() = accountDataSource.getSelectedAccount() 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 import co.electriccoin.zcash.ui.common.repository.WalletRepository
class ObserveWalletStateUseCase( class GetWalletStateInformationUseCase(
private val walletRepository: WalletRepository private val walletRepository: WalletRepository
) { ) {
operator fun invoke() = walletRepository.walletStateInformation fun observe() = walletRepository.walletStateInformation
} }

View File

@ -0,0 +1,32 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
class IsRestoreSuccessDialogVisibleUseCase(
private val standardPreferenceProvider: StandardPreferenceProvider,
private val walletRepository: WalletRepository,
) {
fun observe() =
combine(
walletRepository.walletRestoringState,
flow {
emitAll(StandardPreferenceKeys.IS_RESTORING_INITIAL_WARNING_SEEN.observe(standardPreferenceProvider()))
}
) { walletRestoringState, isSeen ->
walletRestoringState == WalletRestoringState.RESTORING && !isSeen
}
suspend fun setSeen() {
StandardPreferenceKeys.IS_RESTORING_INITIAL_WARNING_SEEN
.putValue(
preferenceProvider = standardPreferenceProvider(),
newValue = true
)
}
}

View File

@ -9,10 +9,14 @@ class NavigateToCoinbaseUseCase(
private val accountDataSource: AccountDataSource, private val accountDataSource: AccountDataSource,
private val navigationRouter: NavigationRouter private val navigationRouter: NavigationRouter
) { ) {
suspend operator fun invoke() { suspend operator fun invoke(replaceCurrentScreen: Boolean) {
val transparent = accountDataSource.getZashiAccount().transparent val transparent = accountDataSource.getZashiAccount().transparent
val url = getUrl(transparent.address.address) val url = getUrl(transparent.address.address)
navigationRouter.forward(ExternalUrl(url)) if (replaceCurrentScreen) {
navigationRouter.replace(ExternalUrl(url))
} else {
navigationRouter.forward(ExternalUrl(url))
}
} }
private fun getUrl(address: String): String { private fun getUrl(address: String): String {

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 package co.electriccoin.zcash.ui.common.usecase
import cash.z.ecc.android.sdk.model.Zatoshi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -8,14 +9,37 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class PrefillSendUseCase { class PrefillSendUseCase {
private val bus = Channel<DetailedTransactionData>() private val bus = Channel<PrefillSendData>()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
operator fun invoke() = bus.receiveAsFlow() operator fun invoke() = bus.receiveAsFlow()
fun request(value: DetailedTransactionData) = 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 { scope.launch {
bus.send(value) 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 package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.HomeTabNavigationRouter
import co.electriccoin.zcash.ui.NavigationRouter import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex import co.electriccoin.zcash.ui.screen.send.Send
class SendTransactionAgainUseCase( class SendTransactionAgainUseCase(
private val prefillSendUseCase: PrefillSendUseCase, private val prefillSendUseCase: PrefillSendUseCase,
private val homeTabNavigationRouter: HomeTabNavigationRouter,
private val navigationRouter: NavigationRouter private val navigationRouter: NavigationRouter
) { ) {
operator fun invoke(value: DetailedTransactionData) { operator fun invoke(value: DetailedTransactionData) {
homeTabNavigationRouter.select(HomeScreenIndex.SEND)
prefillSendUseCase.request(value) prefillSendUseCase.request(value)
navigationRouter.backToRoot() navigationRouter.forward(Send())
} }
} }

View File

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

View File

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

View File

@ -1,11 +1,12 @@
package co.electriccoin.zcash.ui.common.usecase package co.electriccoin.zcash.ui.common.usecase
import PaymentRequest
import cash.z.ecc.android.sdk.type.AddressType import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.zecdev.zip321.ZIP321 import org.zecdev.zip321.ZIP321
internal class Zip321ParseUriValidationUseCase( class Zip321ParseUriValidationUseCase(
private val getSynchronizerUseCase: GetSynchronizerUseCase private val getSynchronizerUseCase: GetSynchronizerUseCase
) { ) {
operator fun invoke(zip321Uri: String) = validateZip321Uri(zip321Uri) operator fun invoke(zip321Uri: String) = validateZip321Uri(zip321Uri)
@ -24,6 +25,7 @@ internal class Zip321ParseUriValidationUseCase(
Twig.error { "Address from Zip321 validation failed: ${validation.reason}" } Twig.error { "Address from Zip321 validation failed: ${validation.reason}" }
false false
} }
else -> { else -> {
validation is AddressType.Valid validation is AddressType.Valid
} }
@ -41,15 +43,19 @@ internal class Zip321ParseUriValidationUseCase(
Twig.info { "Payment Request Zip321 validation result: $paymentRequest." } Twig.info { "Payment Request Zip321 validation result: $paymentRequest." }
return when (paymentRequest) { return when (paymentRequest) {
is ZIP321.ParserResult.Request -> Zip321ParseUriValidation.Valid(zip321Uri) is ZIP321.ParserResult.Request -> Zip321ParseUriValidation.Valid(zip321Uri, paymentRequest.paymentRequest)
is ZIP321.ParserResult.SingleAddress -> is ZIP321.ParserResult.SingleAddress ->
Zip321ParseUriValidation.SingleAddress(paymentRequest.singleRecipient.value) Zip321ParseUriValidation.SingleAddress(paymentRequest.singleRecipient.value)
else -> Zip321ParseUriValidation.Invalid else -> Zip321ParseUriValidation.Invalid
} }
} }
internal sealed class Zip321ParseUriValidation { sealed class Zip321ParseUriValidation {
data class Valid(val zip321Uri: String) : Zip321ParseUriValidation() data class Valid(
val zip321Uri: String,
val payment: PaymentRequest,
) : Zip321ParseUriValidation()
data class SingleAddress(val address: String) : 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.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class HomeViewModel( class OldHomeViewModel(
private val observeConfiguration: ObserveConfigurationUseCase, observeConfiguration: ObserveConfigurationUseCase,
private val standardPreferenceProvider: StandardPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider,
) : ViewModel() { ) : ViewModel() {
/** /**
@ -28,39 +25,13 @@ class HomeViewModel(
val isBackgroundSyncEnabled: StateFlow<Boolean?> = val isBackgroundSyncEnabled: StateFlow<Boolean?> =
booleanStateFlow(StandardPreferenceKeys.IS_BACKGROUND_SYNC_ENABLED) 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. * A flow of the wallet balances visibility.
*/ */
val isHideBalances: StateFlow<Boolean?> = booleanStateFlow(StandardPreferenceKeys.IS_HIDE_BALANCES) 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() val configurationFlow: StateFlow<Configuration?> = observeConfiguration()
//
// PRIVATE HELPERS
//
private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> = private fun booleanStateFlow(default: BooleanPreferenceDefault): StateFlow<Boolean?> =
flow<Boolean?> { flow<Boolean?> {
emitAll(default.observe(standardPreferenceProvider())) emitAll(default.observe(standardPreferenceProvider()))
@ -69,13 +40,4 @@ class HomeViewModel(
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null 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.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider 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.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
@ -42,7 +41,6 @@ import kotlinx.coroutines.launch
@Suppress("LongParameterList", "TooManyFunctions") @Suppress("LongParameterList", "TooManyFunctions")
class WalletViewModel( class WalletViewModel(
application: Application, application: Application,
balanceRepository: BalanceRepository,
private val walletCoordinator: WalletCoordinator, private val walletCoordinator: WalletCoordinator,
private val walletRepository: WalletRepository, private val walletRepository: WalletRepository,
private val exchangeRateRepository: ExchangeRateRepository, private val exchangeRateRepository: ExchangeRateRepository,
@ -55,8 +53,6 @@ class WalletViewModel(
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
val synchronizer = walletRepository.synchronizer val synchronizer = walletRepository.synchronizer
val walletRestoringState = walletRepository.walletRestoringState
val walletStateInformation = walletRepository.walletStateInformation val walletStateInformation = walletRepository.walletStateInformation
val secretState: StateFlow<SecretState> = walletRepository.secretState val secretState: StateFlow<SecretState> = walletRepository.secretState
@ -67,12 +63,6 @@ class WalletViewModel(
val exchangeRateUsd = exchangeRateRepository.state val exchangeRateUsd = exchangeRateRepository.state
val balanceState = balanceRepository.state
fun refreshExchangeRateUsd() {
exchangeRateRepository.refreshExchangeRateUsd()
}
fun optInExchangeRateUsd(optIn: Boolean) { fun optInExchangeRateUsd(optIn: Boolean) {
exchangeRateRepository.optInExchangeRateUsd(optIn) exchangeRateRepository.optInExchangeRateUsd(optIn)
} }
@ -111,21 +101,6 @@ class WalletViewModel(
walletRepository.persistOnboardingState(onboardingState) 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( fun persistExistingWalletWithSeedPhrase(
network: ZcashNetwork, network: ZcashNetwork,
seedPhrase: SeedPhrase, seedPhrase: SeedPhrase,

View File

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

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.ui.fixture 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.R
import co.electriccoin.zcash.ui.design.component.AccountSwitchState
import co.electriccoin.zcash.ui.design.component.IconButtonState 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.design.util.stringRes
object ZashiMainTopAppBarStateFixture { 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() bottomSheetHiddenResponse.first()
} }
private fun onBottomSheetHidden() =
viewModelScope.launch {
bottomSheetHiddenResponse.emit(Unit)
}
private fun onAccountClicked(account: WalletAccount) = private fun onAccountClicked(account: WalletAccount) =
viewModelScope.launch { viewModelScope.launch {
selectWalletAccount(account) { hideBottomSheet() } selectWalletAccount(account) { hideBottomSheet() }
@ -115,11 +120,6 @@ class AccountListViewModel(
navigationRouter.forward(ConnectKeystone) navigationRouter.forward(ConnectKeystone)
} }
private fun onBottomSheetHidden() =
viewModelScope.launch {
bottomSheetHiddenResponse.emit(Unit)
}
private fun onBack() = private fun onBack() =
viewModelScope.launch { viewModelScope.launch {
hideBottomSheet() 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.addressbook.model.AddressBookState
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
import co.electriccoin.zcash.ui.screen.contact.UpdateContactArgs 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.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
@ -85,7 +85,7 @@ class AddressBookViewModel(
private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null)) 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 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.AddressBookItem
import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState import co.electriccoin.zcash.ui.screen.addressbook.model.AddressBookState
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs 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.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
@ -174,5 +174,5 @@ class SelectRecipientViewModel(
private fun onAddContactManuallyClick() = navigationRouter.forward(AddContactArgs(null)) 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. * These are only used for automated testing.
*/ */
object BalancesTag { object BalanceTag {
const val STATUS = "status" 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.component.LottieProgress
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme 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.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.StringResource import co.electriccoin.zcash.ui.design.util.StringResource
@ -56,7 +57,6 @@ fun StyledExchangeBalance(
zatoshi: Zatoshi, zatoshi: Zatoshi,
state: ExchangeRateState, state: ExchangeRateState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isHideBalances: Boolean = false,
hiddenBalancePlaceholder: StringResource = hiddenBalancePlaceholder: StringResource =
stringRes(co.electriccoin.zcash.ui.design.R.string.hide_balance_placeholder), stringRes(co.electriccoin.zcash.ui.design.R.string.hide_balance_placeholder),
textColor: Color = ZashiColors.Text.textPrimary, textColor: Color = ZashiColors.Text.textPrimary,
@ -75,9 +75,9 @@ fun StyledExchangeBalance(
style = style, style = style,
textColor = textColor, textColor = textColor,
zatoshi = zatoshi, zatoshi = zatoshi,
isHideBalances = isHideBalances,
state = state, state = state,
hiddenBalancePlaceholder = hiddenBalancePlaceholder hiddenBalancePlaceholder = hiddenBalancePlaceholder,
isHideBalance = LocalBalancesAvailable.current.not()
) )
} }
@ -97,9 +97,9 @@ private fun ExchangeAvailableRateLabelInternal(
style: TextStyle, style: TextStyle,
textColor: Color, textColor: Color,
zatoshi: Zatoshi, zatoshi: Zatoshi,
isHideBalances: Boolean,
state: ExchangeRateState.Data, state: ExchangeRateState.Data,
hiddenBalancePlaceholder: StringResource, hiddenBalancePlaceholder: StringResource,
isHideBalance: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isEnabled = !state.isLoading && state.isRefreshEnabled val isEnabled = !state.isLoading && state.isRefreshEnabled
@ -114,7 +114,7 @@ private fun ExchangeAvailableRateLabelInternal(
textColor = textColor, textColor = textColor,
) { ) {
Text( Text(
text = createExchangeRateText(state, isHideBalances, hiddenBalancePlaceholder, zatoshi), text = createExchangeRateText(state, hiddenBalancePlaceholder, zatoshi, isHideBalance),
style = style, style = style,
maxLines = 1, maxLines = 1,
color = textColor color = textColor
@ -148,9 +148,9 @@ private fun ExchangeAvailableRateLabelInternal(
@Composable @Composable
internal fun createExchangeRateText( internal fun createExchangeRateText(
state: ExchangeRateState.Data, state: ExchangeRateState.Data,
isHideBalances: Boolean,
hiddenBalancePlaceholder: StringResource, hiddenBalancePlaceholder: StringResource,
zatoshi: Zatoshi zatoshi: Zatoshi,
isHideBalances: Boolean
): String { ): String {
val currencySymbol = state.fiatCurrency.symbol val currencySymbol = state.fiatCurrency.symbol
val text = val text =
@ -268,7 +268,6 @@ private fun ExchangeRateButton(
) )
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun DefaultPreview() = private fun DefaultPreview() =
@ -276,7 +275,6 @@ private fun DefaultPreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = state =
@ -294,7 +292,6 @@ private fun DefaultPreview() =
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun DefaultNoRefreshPreview() = private fun DefaultNoRefreshPreview() =
@ -302,7 +299,6 @@ private fun DefaultNoRefreshPreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = state =
@ -321,7 +317,6 @@ private fun DefaultNoRefreshPreview() =
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun HiddenPreview() = private fun HiddenPreview() =
@ -329,7 +324,6 @@ private fun HiddenPreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = true,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = state =
@ -347,7 +341,6 @@ private fun HiddenPreview() =
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun HiddenStalePreview() = private fun HiddenStalePreview() =
@ -355,7 +348,6 @@ private fun HiddenStalePreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = true,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = state =
@ -374,7 +366,6 @@ private fun HiddenStalePreview() =
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun LoadingPreview() = private fun LoadingPreview() =
@ -382,7 +373,6 @@ private fun LoadingPreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = ObserveFiatCurrencyResultFixture.new() state = ObserveFiatCurrencyResultFixture.new()
@ -391,7 +381,6 @@ private fun LoadingPreview() =
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun LoadingEmptyPreview() = private fun LoadingEmptyPreview() =
@ -399,7 +388,6 @@ private fun LoadingEmptyPreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = ObserveFiatCurrencyResultFixture.new(currencyConversion = null) state = ObserveFiatCurrencyResultFixture.new(currencyConversion = null)
@ -408,7 +396,6 @@ private fun LoadingEmptyPreview() =
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun LoadingStalePreview() = private fun LoadingStalePreview() =
@ -416,7 +403,6 @@ private fun LoadingStalePreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = state =
@ -429,7 +415,6 @@ private fun LoadingStalePreview() =
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun StalePreview() = private fun StalePreview() =
@ -437,7 +422,6 @@ private fun StalePreview() =
BlankSurface { BlankSurface {
Column { Column {
StyledExchangeBalance( StyledExchangeBalance(
isHideBalances = false,
modifier = Modifier, modifier = Modifier,
zatoshi = Zatoshi(1), zatoshi = Zatoshi(1),
state = state =

View File

@ -32,7 +32,7 @@ fun StyledExchangeLabel(
if (!state.isStale && state.currencyConversion != null) { if (!state.isStale && state.currencyConversion != null) {
Text( Text(
modifier = modifier, modifier = modifier,
text = createExchangeRateText(state, isHideBalances, hiddenBalancePlaceholder, zatoshi), text = createExchangeRateText(state, hiddenBalancePlaceholder, zatoshi, isHideBalances),
maxLines = 1, maxLines = 1,
color = textColor, color = textColor,
style = style, 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,43 @@
package co.electriccoin.zcash.ui.screen.home 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.HomeTabNavigationRouter import co.electriccoin.zcash.ui.common.appbar.ZashiTopAppBarViewModel
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.screen.balances.BalanceViewModel
import co.electriccoin.zcash.ui.common.compose.LocalActivity import co.electriccoin.zcash.ui.screen.restoresuccess.WrapRestoreSuccess
import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.screen.transactionhistory.widget.TransactionHistoryWidgetViewModel
import co.electriccoin.zcash.ui.common.model.WalletSnapshot import kotlinx.serialization.Serializable
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
@Composable @Composable
@Suppress("LongParameterList") internal fun AndroidHome() {
internal fun WrapHome( val topAppBarViewModel = koinActivityViewModel<ZashiTopAppBarViewModel>()
goScan: () -> Unit, val balanceViewModel = koinActivityViewModel<BalanceViewModel>()
sendArguments: SendArguments
) {
val homeViewModel = koinActivityViewModel<HomeViewModel>() val homeViewModel = koinActivityViewModel<HomeViewModel>()
val transactionHistoryWidgetViewModel = koinActivityViewModel<TransactionHistoryWidgetViewModel>()
val restoreDialogState by homeViewModel.restoreDialogState.collectAsStateWithLifecycle()
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 walletViewModel = koinActivityViewModel<WalletViewModel>() state?.let {
HomeView(
val isKeepScreenOnWhileSyncing = homeViewModel.isKeepScreenOnWhileSyncing.collectAsStateWithLifecycle().value appBarState = appBarState,
balanceState = balanceState,
val walletSnapshot = walletViewModel.currentWalletSnapshot.collectAsStateWithLifecycle().value state = it,
transactionWidgetState = transactionWidgetState
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 }
) )
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) { if (restoreDialogState != null) {
homeTabNavigationRouter.observe().collect { WrapRestoreSuccess(
pagerState.scrollToPage(it.pageIndex) onDone = { restoreDialogState?.onClick?.invoke() }
}
}
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)
}
} }
} }
/** @Serializable
* Enum of the Home screen sub-screens object Home
*/
@Suppress("MagicNumber")
enum class HomeScreenIndex(val pageIndex: Int) {
ACCOUNT(0),
SEND(1),
RECEIVE(2),
BALANCES(3)
}

View File

@ -0,0 +1,14 @@
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,
)
data class HomeRestoreDialogState(
val onClick: () -> Unit
)

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,6 @@
package co.electriccoin.zcash.ui.screen.home
object HomeTags {
const val SEND = "SEND"
const val RECEIVE = "RECEIVE"
}

View File

@ -0,0 +1,187 @@
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.platform.testTag
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)
.testTag(HomeTags.RECEIVE),
state = state.receiveButton,
)
ZashiBigIconButton(
modifier =
Modifier
.weight(1f)
.testTag(HomeTags.SEND),
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,100 @@
package co.electriccoin.zcash.ui.screen.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.NavigationRouter
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase
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.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class HomeViewModel(
private val navigationRouter: NavigationRouter,
private val isRestoreSuccessDialogVisible: IsRestoreSuccessDialogVisibleUseCase
) : ViewModel() {
private val isRestoreDialogVisible: Flow<Boolean?> =
isRestoreSuccessDialogVisible.observe()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
val restoreDialogState: StateFlow<HomeRestoreDialogState?> =
isRestoreDialogVisible
.map { isVisible ->
HomeRestoreDialogState(
onClick = ::onRestoreDialogSeenClick
).takeIf { isVisible == true }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
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()
fun onRestoreDialogSeenClick() =
viewModelScope.launch {
isRestoreSuccessDialogVisible.setSeen()
}
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.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.integrations.view.Integrations import co.electriccoin.zcash.ui.screen.integrations.view.Integrations
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import com.flexa.core.Flexa import kotlinx.serialization.Serializable
import com.flexa.spend.buildSpend
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable @Composable
internal fun WrapIntegrations() { fun AndroidIntegrations() {
val activity = LocalActivity.current val walletViewModel = koinViewModel<WalletViewModel>()
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<IntegrationsViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle()
val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value
LaunchedEffect(Unit) { val viewModel = koinViewModel<IntegrationsViewModel> { parametersOf(false) }
viewModel.flexaNavigationCommand.collect { val state by viewModel.state.collectAsStateWithLifecycle()
Flexa.buildSpend()
.onTransactionRequest {
viewModel.onFlexaResultCallback(it)
}
.build()
.open(activity)
}
}
BackHandler { BackHandler(enabled = state != null) {
viewModel.onBack() state?.onBack?.invoke()
} }
state?.let { 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 disabledInfo: StringResource?,
val onBack: () -> Unit, val onBack: () -> Unit,
val items: ImmutableList<ZashiListItemState>, 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.Image
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -64,24 +65,7 @@ fun Integrations(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.scaffoldScrollPadding(paddingValues), .scaffoldScrollPadding(paddingValues),
) { ) {
state.items.forEachIndexed { index, item -> IntegrationItems(state)
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)
)
}
}
state.disabledInfo?.let { state.disabledInfo?.let {
Spacer(modifier = Modifier.height(28.dp)) 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 @Composable
private fun DisabledInfo(it: StringResource) { private fun DisabledInfo(it: StringResource) {
Row( Row(
@ -187,7 +197,8 @@ private fun IntegrationSettings() =
icon = R.drawable.ic_integrations_keystone, icon = R.drawable.ic_integrations_keystone,
onClick = {} onClick = {}
), ),
) ),
onBottomSheetHidden = {}
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
) )

View File

@ -1,85 +1,60 @@
package co.electriccoin.zcash.ui.screen.integrations.viewmodel package co.electriccoin.zcash.ui.screen.integrations.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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 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.NavigationRouter
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.KeystoneAccount 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.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.repository.BiometricRepository import co.electriccoin.zcash.ui.common.usecase.GetWalletRestoringStateUseCase
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.IsCoinbaseAvailableUseCase import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase import co.electriccoin.zcash.ui.common.usecase.IsFlexaAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletAccountsUseCase 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.component.listitem.ZashiListItemState
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.connectkeystone.ConnectKeystone 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.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.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class IntegrationsViewModel( class IntegrationsViewModel(
getZcashCurrency: GetZcashCurrencyProvider, getZcashCurrency: GetZcashCurrencyProvider,
observeWalletState: ObserveWalletStateUseCase, getWalletRestoringState: GetWalletRestoringStateUseCase,
isFlexaAvailableUseCase: IsFlexaAvailableUseCase, isFlexaAvailableUseCase: IsFlexaAvailableUseCase,
isCoinbaseAvailable: IsCoinbaseAvailableUseCase, isCoinbaseAvailable: IsCoinbaseAvailableUseCase,
private val getSynchronizer: GetSynchronizerUseCase, observeWalletAccounts: ObserveWalletAccountsUseCase,
private val getZashiAccount: GetZashiAccountUseCase, private val isDialog: Boolean,
private val isFlexaAvailable: IsFlexaAvailableUseCase,
private val getSpendingKey: GetZashiSpendingKeyUseCase,
private val context: Context,
private val biometricRepository: BiometricRepository,
private val navigationRouter: NavigationRouter, private val navigationRouter: NavigationRouter,
private val navigateToCoinbase: NavigateToCoinbaseUseCase, private val navigateToCoinbase: NavigateToCoinbaseUseCase,
private val observeWalletAccounts: ObserveWalletAccountsUseCase,
) : ViewModel() { ) : ViewModel() {
val flexaNavigationCommand = MutableSharedFlow<Unit>() val hideBottomSheetRequest = MutableSharedFlow<Unit>()
private val isEnabled = private val bottomSheetHiddenResponse = MutableSharedFlow<Unit>()
observeWalletState()
.map { private val isRestoring = getWalletRestoringState.observe().map { it == WalletRestoringState.RESTORING }
it != TopAppBarSubTitleState.Restoring
}
val state = val state =
combine( combine(
isFlexaAvailableUseCase.observe(), isFlexaAvailableUseCase.observe(),
isCoinbaseAvailable.observe(), isCoinbaseAvailable.observe(),
isEnabled, isRestoring,
observeWalletAccounts() observeWalletAccounts()
) { isFlexaAvailable, isCoinbaseAvailable, isEnabled, accounts -> ) { isFlexaAvailable, isCoinbaseAvailable, isRestoring, accounts ->
IntegrationsState( IntegrationsState(
disabledInfo = stringRes(R.string.integrations_disabled_info).takeIf { isEnabled.not() }, disabledInfo =
stringRes(R.string.integrations_disabled_info)
.takeIf { isRestoring },
onBack = ::onBack, onBack = ::onBack,
items = items =
listOfNotNull( listOfNotNull(
@ -98,9 +73,9 @@ class IntegrationsViewModel(
ZashiListItemState( ZashiListItemState(
// Set the wallet currency by app build is more future-proof, although we hide it from // Set the wallet currency by app build is more future-proof, although we hide it from
// the UI in the Testnet build // the UI in the Testnet build
isEnabled = isEnabled, isEnabled = isRestoring.not(),
icon = icon =
if (isEnabled) { if (isRestoring.not()) {
R.drawable.ic_integrations_flexa R.drawable.ic_integrations_flexa
} else { } else {
R.drawable.ic_integrations_flexa_disabled R.drawable.ic_integrations_flexa_disabled
@ -115,7 +90,8 @@ class IntegrationsViewModel(
icon = R.drawable.ic_integrations_keystone, icon = R.drawable.ic_integrations_keystone,
onClick = ::onConnectKeystoneClick onClick = ::onConnectKeystoneClick
).takeIf { accounts.orEmpty().none { it is KeystoneAccount } }, ).takeIf { accounts.orEmpty().none { it is KeystoneAccount } },
).toImmutableList() ).toImmutableList(),
onBottomSheetHidden = ::onBottomSheetHidden
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -123,186 +99,40 @@ class IntegrationsViewModel(
initialValue = null 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() = private fun onBuyWithCoinbaseClicked() =
viewModelScope.launch { viewModelScope.launch {
navigateToCoinbase() hideBottomSheet()
navigateToCoinbase(isDialog)
} }
private fun onConnectKeystoneClick() = navigationRouter.forward(ConnectKeystone) private fun onConnectKeystoneClick() =
viewModelScope.launch {
hideBottomSheet()
navigationRouter.replace(ConnectKeystone)
}
private fun onFlexaClicked() = private fun onFlexaClicked() =
viewModelScope.launch { viewModelScope.launch {
if (isFlexaAvailable()) { if (isDialog) {
flexaNavigationCommand.emit(Unit) hideBottomSheet()
} navigationRouter.replace(Flexa)
}
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
}
} else { } else {
// All transaction submissions were successful hideBottomSheet()
SubmitResult.Success(emptyList()) to navigationRouter.forward(Flexa)
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

@ -2,23 +2,28 @@
package co.electriccoin.zcash.ui.screen.receive package co.electriccoin.zcash.ui.screen.receive
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel 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.view.ReceiveView
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
@Composable @Composable
internal fun WrapReceive() { internal fun AndroidReceive() {
val receiveViewModel = koinActivityViewModel<ReceiveViewModel>() val receiveViewModel = koinActivityViewModel<ReceiveViewModel>()
val receiveState by receiveViewModel.state.collectAsStateWithLifecycle() val state by receiveViewModel.state.collectAsStateWithLifecycle()
val topAppBarViewModel = koinActivityViewModel<ZashiMainTopAppBarViewModel>() val topAppBarViewModel = koinActivityViewModel<ZashiTopAppBarViewModel>()
val zashiMainTopAppBarState by topAppBarViewModel.state.collectAsStateWithLifecycle() val appBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
BackHandler {
state.onBack()
}
ReceiveView( ReceiveView(
state = receiveState, state = state,
zashiMainTopAppBarState = zashiMainTopAppBarState, 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( data class ReceiveState(
val items: List<ReceiveAddressState>?, val items: List<ReceiveAddressState>?,
val isLoading: Boolean val isLoading: Boolean,
val onBack: () -> Unit
) )
data class ReceiveAddressState( 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R 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.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator 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.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors 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.dimensions.ZashiDimensions
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.design.util.getValue 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.design.util.stringRes
import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture import co.electriccoin.zcash.ui.fixture.ZashiMainTopAppBarStateFixture
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressState import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressState
@ -49,7 +50,7 @@ import co.electriccoin.zcash.ui.screen.receive.model.ReceiveState
@Composable @Composable
internal fun ReceiveView( internal fun ReceiveView(
state: ReceiveState, state: ReceiveState,
zashiMainTopAppBarState: ZashiMainTopAppBarState?, appBarState: ZashiMainTopAppBarState?,
) { ) {
when { when {
state.items.isNullOrEmpty() && state.isLoading -> { state.items.isNullOrEmpty() && state.isLoading -> {
@ -59,15 +60,20 @@ internal fun ReceiveView(
else -> { else -> {
BlankBgScaffold( BlankBgScaffold(
topBar = { topBar = {
ZashiMainTopAppBar(state = zashiMainTopAppBarState, showHideBalances = false) ZashiTopAppbar(
title = null,
state = appBarState,
showHideBalances = false,
onBack = state.onBack
)
}, },
) { paddingValues -> ) { paddingValues ->
ReceiveContents( ReceiveContents(
items = state.items.orEmpty(), items = state.items.orEmpty(),
modifier = modifier =
Modifier.padding( Modifier.scaffoldScrollPadding(
paddingValues = paddingValues,
top = paddingValues.calculateTopPadding() 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() = private fun LoadingPreview() =
ZcashTheme(forceDarkMode = true) { ZcashTheme(forceDarkMode = true) {
ReceiveView( ReceiveView(
state = ReceiveState(items = null, isLoading = true), state =
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new() ReceiveState(
items = null,
isLoading = true,
onBack = {}
),
appBarState = ZashiMainTopAppBarStateFixture.new()
) )
} }
@ -303,8 +314,9 @@ private fun ZashiPreview() =
onClick = {} onClick = {}
) )
), ),
isLoading = false isLoading = false,
onBack = {}
), ),
zashiMainTopAppBarState = ZashiMainTopAppBarStateFixture.new() appBarState = ZashiMainTopAppBarStateFixture.new()
) )
} }

View File

@ -55,12 +55,18 @@ class ReceiveViewModel(
onClick = { onAddressClick(1) } onClick = { onAddressClick(1) }
), ),
), ),
isLoading = false isLoading = false,
onBack = ::onBack
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = ReceiveState(items = null, isLoading = true) initialValue =
ReceiveState(
items = null,
isLoading = true,
onBack = ::onBack
)
) )
init { init {
@ -71,6 +77,8 @@ class ReceiveViewModel(
} }
} }
private fun onBack() = navigationRouter.back()
private fun createAddressState( private fun createAddressState(
account: WalletAccount, account: WalletAccount,
address: String, address: String,
@ -96,6 +104,7 @@ class ReceiveViewModel(
} else { } else {
stringRes(R.string.receive_wallet_address_transparent_keystone) stringRes(R.string.receive_wallet_address_transparent_keystone)
} }
is ZashiAccount -> is ZashiAccount ->
if (type == ReceiveAddressType.Unified) { if (type == ReceiveAddressType.Unified) {
stringRes(R.string.receive_wallet_address_shielded) 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.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.sdk.extension.toZecStringFull import cash.z.ecc.sdk.extension.toZecStringFull
import cash.z.ecc.sdk.fixture.ZatoshiFixture 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.extension.asZecAmountTriple
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.R 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.getValue
import co.electriccoin.zcash.ui.design.util.scaffoldPadding import co.electriccoin.zcash.ui.design.util.scaffoldPadding
import co.electriccoin.zcash.ui.design.util.stringRes 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 co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeLabel
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@ -340,7 +340,7 @@ private fun AmountWidget(state: AmountState) {
color = ZashiColors.Text.textPrimary color = ZashiColors.Text.textPrimary
) )
} }
BalanceWidgetBigLineOnly( BalanceWidgetHeader(
parts = state.amount.toZecStringFull().asZecAmountTriple(), parts = state.amount.toZecStringFull().asZecAmountTriple(),
isHideBalances = false isHideBalances = false
) )

View File

@ -3,21 +3,17 @@ package co.electriccoin.zcash.ui.screen.scan
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel 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.R
import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.popBackStackJustOnce 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.view.Scan
import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel
import co.electriccoin.zcash.ui.util.SettingsUtil import co.electriccoin.zcash.ui.util.SettingsUtil
@ -26,7 +22,7 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@Composable @Composable
internal fun WrapScanValidator(args: ScanNavigationArgs) { internal fun WrapScanValidator(args: Scan) {
val navController = LocalNavController.current val navController = LocalNavController.current
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -38,26 +34,7 @@ internal fun WrapScanValidator(args: ScanNavigationArgs) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler { BackHandler {
navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) navController.popBackStackJustOnce(Scan.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)
}
} }
if (synchronizer == null) { if (synchronizer == null) {
@ -69,7 +46,7 @@ internal fun WrapScanValidator(args: ScanNavigationArgs) {
Scan( Scan(
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
validationResult = state, validationResult = state,
onBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) }, onBack = { navController.popBackStackJustOnce(Scan.ROUTE) },
onScanned = { onScanned = {
viewModel.onScanned(it) viewModel.onScanned(it)
}, },
@ -95,8 +72,9 @@ internal fun WrapScanValidator(args: ScanNavigationArgs) {
} }
} }
enum class ScanNavigationArgs { enum class Scan {
DEFAULT, HOMEPAGE,
SEND,
ADDRESS_BOOK; ADDRESS_BOOK;
companion object { companion object {
@ -104,6 +82,6 @@ enum class ScanNavigationArgs {
const val KEY = "mode" const val KEY = "mode"
const val ROUTE = "$PATH/{$KEY}" 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.type.AddressType 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.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
import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase.Zip321ParseUriValidation import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase.Zip321ParseUriValidation
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs import co.electriccoin.zcash.ui.screen.scan.Scan
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.model.ScanValidationState import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
internal class ScanViewModel( internal class ScanViewModel(
private val args: ScanNavigationArgs, private val args: Scan,
private val getSynchronizer: GetSynchronizerUseCase, private val getSynchronizer: GetSynchronizerUseCase,
private val zip321ParseUriValidationUseCase: Zip321ParseUriValidationUseCase, private val zip321ParseUriValidationUseCase: Zip321ParseUriValidationUseCase,
private val onAddressScanned: OnAddressScannedUseCase,
private val zip321Scanned: OnZip321ScannedUseCase
) : ViewModel() { ) : ViewModel() {
val navigateBack = MutableSharedFlow<ScanResultState>() val state = MutableStateFlow(ScanValidationState.NONE)
val navigateCommand = MutableSharedFlow<String>()
var state = MutableStateFlow(ScanValidationState.NONE)
private val mutex = Mutex() private val mutex = Mutex()
@ -40,77 +33,58 @@ internal class ScanViewModel(
viewModelScope.launch { viewModelScope.launch {
mutex.withLock { mutex.withLock {
if (!hasBeenScannedSuccessfully) { if (!hasBeenScannedSuccessfully) {
val addressValidationResult = getSynchronizer().validateAddress(result) runCatching {
val zip321ValidationResult = zip321ParseUriValidationUseCase(result) val zip321ValidationResult = zip321ParseUriValidationUseCase(result)
val addressValidationResult = getSynchronizer().validateAddress(result)
when { when {
zip321ValidationResult is Zip321ParseUriValidation.Valid -> zip321ValidationResult is Zip321ParseUriValidation.Valid ->
{ onZip321Scanned(zip321ValidationResult)
hasBeenScannedSuccessfully = true
state.update { ScanValidationState.VALID } zip321ValidationResult is Zip321ParseUriValidation.SingleAddress ->
navigateBack.emit(ScanResultState.Zip321Uri(zip321ValidationResult.zip321Uri)) onZip321SingleAddressScanned(zip321ValidationResult)
}
zip321ValidationResult is Zip321ParseUriValidation.SingleAddress -> addressValidationResult is AddressType.Valid ->
{ onAddressScanned(result, addressValidationResult)
hasBeenScannedSuccessfully = true
val singleAddressValidation = else -> onInvalidScan()
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 }
} }
} }
} }
} }
} }
private suspend fun processAddress( private fun onInvalidScan() {
address: String, hasBeenScannedSuccessfully = false
addressType: AddressType 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 = private suspend fun onZip321SingleAddressScanned(zip321ValidationResult: Zip321ParseUriValidation.SingleAddress) {
SerializableAddress( hasBeenScannedSuccessfully = true
address = address, val singleAddressValidation = getSynchronizer().validateAddress(zip321ValidationResult.address)
type = addressType if (singleAddressValidation is AddressType.Invalid) {
) state.update { ScanValidationState.INVALID }
} else {
when (args) { state.update { ScanValidationState.VALID }
DEFAULT -> { onAddressScanned(zip321ValidationResult.address, singleAddressValidation, args)
navigateBack.emit(
ScanResultState.Address(
Json.encodeToString(
SerializableAddress.serializer(),
serializableAddress
)
)
)
}
ADDRESS_BOOK -> {
navigateCommand.emit(AddContactArgs(serializableAddress.address))
}
} }
} }
private suspend fun onZip321Scanned(zip321ValidationResult: Zip321ParseUriValidation.Valid) {
hasBeenScannedSuccessfully = true
state.update { ScanValidationState.VALID }
zip321Scanned(zip321ValidationResult, args)
}
fun onScannedError() = fun onScannedError() =
viewModelScope.launch { viewModelScope.launch {
mutex.withLock { 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.model.toZecString
import cash.z.ecc.android.sdk.type.AddressType import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.di.koinActivityViewModel 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.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController 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.ObserveClearSendUseCase
import co.electriccoin.zcash.ui.common.usecase.PrefillSendData
import co.electriccoin.zcash.ui.common.usecase.PrefillSendUseCase 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.WalletViewModel
import co.electriccoin.zcash.ui.common.viewmodel.ZashiMainTopAppBarViewModel
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator 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.ext.Saver
import co.electriccoin.zcash.ui.screen.send.model.AmountState 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.MemoState
import co.electriccoin.zcash.ui.screen.send.model.RecipientAddressState 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.model.SendStage
import co.electriccoin.zcash.ui.screen.send.view.Send import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -43,45 +46,39 @@ import org.koin.compose.koinInject
import java.util.Locale import java.util.Locale
@Composable @Composable
@Suppress("LongParameterList") internal fun WrapSend(args: Send) {
internal fun WrapSend(
sendArguments: SendArguments?,
goToQrScanner: () -> Unit,
goBack: () -> Unit,
goBalances: () -> Unit,
) {
val activity = LocalActivity.current val activity = LocalActivity.current
val navigationRouter = koinInject<NavigationRouter>()
val walletViewModel = koinActivityViewModel<WalletViewModel>() 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 hasCameraFeature = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value 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 monetarySeparators = MonetarySeparators.current(Locale.getDefault())
val balanceState = walletViewModel.balanceState.collectAsStateWithLifecycle().value val balanceState = balanceViewModel.state.collectAsStateWithLifecycle().value
val isHideBalances = homeViewModel.isHideBalances.collectAsStateWithLifecycle().value ?: false
val exchangeRateState = walletViewModel.exchangeRateUsd.collectAsStateWithLifecycle().value val exchangeRateState = walletViewModel.exchangeRateUsd.collectAsStateWithLifecycle().value
WrapSend( WrapSend(
balanceState = balanceState, balanceState = balanceState,
exchangeRateState = exchangeRateState, exchangeRateState = exchangeRateState,
isHideBalances = isHideBalances, goToQrScanner = { navigationRouter.forward(Scan(Scan.SEND)) },
goToQrScanner = goToQrScanner, goBack = { navigationRouter.back() },
goBack = goBack,
goBalances = goBalances,
hasCameraFeature = hasCameraFeature, hasCameraFeature = hasCameraFeature,
monetarySeparators = monetarySeparators, monetarySeparators = monetarySeparators,
sendArguments = sendArguments, sendArguments = args,
synchronizer = synchronizer, synchronizer = synchronizer,
walletSnapshot = walletSnapshot selectedAccount = selectedAccount
) )
} }
@ -91,15 +88,13 @@ internal fun WrapSend(
internal fun WrapSend( internal fun WrapSend(
balanceState: BalanceState, balanceState: BalanceState,
exchangeRateState: ExchangeRateState, exchangeRateState: ExchangeRateState,
isHideBalances: Boolean,
goToQrScanner: () -> Unit, goToQrScanner: () -> Unit,
goBack: () -> Unit, goBack: () -> Unit,
goBalances: () -> Unit,
hasCameraFeature: Boolean, hasCameraFeature: Boolean,
monetarySeparators: MonetarySeparators, monetarySeparators: MonetarySeparators,
sendArguments: SendArguments?, sendArguments: Send,
synchronizer: Synchronizer?, synchronizer: Synchronizer?,
walletSnapshot: WalletSnapshot?, selectedAccount: WalletAccount?,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -115,7 +110,7 @@ internal fun WrapSend(
val sendAddressBookState by viewModel.sendAddressBookState.collectAsStateWithLifecycle() val sendAddressBookState by viewModel.sendAddressBookState.collectAsStateWithLifecycle()
val topAppBarViewModel = koinActivityViewModel<ZashiMainTopAppBarViewModel>() val topAppBarViewModel = koinActivityViewModel<ZashiTopAppBarViewModel>()
val zashiMainTopAppBarState by topAppBarViewModel.state.collectAsStateWithLifecycle() val zashiMainTopAppBarState by topAppBarViewModel.state.collectAsStateWithLifecycle()
@ -131,27 +126,20 @@ internal fun WrapSend(
val observeClearSend = koinInject<ObserveClearSendUseCase>() val observeClearSend = koinInject<ObserveClearSendUseCase>()
val prefillSend = koinInject<PrefillSendUseCase>() val prefillSend = koinInject<PrefillSendUseCase>()
if (sendArguments?.recipientAddress != null) { if (sendArguments.recipientAddress != null && sendArguments.recipientAddressType != null) {
viewModel.onRecipientAddressChanged( viewModel.onRecipientAddressChanged(
RecipientAddressState.new( RecipientAddressState.new(
sendArguments.recipientAddress.address, sendArguments.recipientAddress,
sendArguments.recipientAddress.type 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: // Amount computation:
val (amountState, setAmountState) = val (amountState, setAmountState) =
rememberSaveable(stateSaver = AmountState.Saver) { rememberSaveable(stateSaver = AmountState.Saver) {
@ -226,51 +214,48 @@ internal fun WrapSend(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
prefillSend().collect { prefillSend().collect {
val type = synchronizer?.validateAddress(it.recipientAddress?.address.orEmpty()) when (it) {
setSendStage(SendStage.Form) is PrefillSendData.All -> {
setZecSend(null) val type = synchronizer?.validateAddress(it.address.orEmpty())
viewModel.onRecipientAddressChanged( setSendStage(SendStage.Form)
RecipientAddressState.new( setZecSend(null)
address = it.recipientAddress?.address.orEmpty(), viewModel.onRecipientAddressChanged(
type = type RecipientAddressState.new(
) address = it.address.orEmpty(),
) type = type
)
)
val fee = it.transaction.fee val fee = it.fee
val value = if (fee == null) it.transaction.amount else it.transaction.amount - fee val value = if (fee == null) it.amount else it.amount - fee
setAmountState( setAmountState(
AmountState.newFromZec( AmountState.newFromZec(
context = context, context = context,
value = value.convertZatoshiToZecString(), value = value.convertZatoshiToZecString(),
monetarySeparators = monetarySeparators, monetarySeparators = monetarySeparators,
isTransparentOrTextRecipient = type == AddressType.Transparent, isTransparentOrTextRecipient = type == AddressType.Transparent,
fiatValue = amountState.fiatValue, fiatValue = amountState.fiatValue,
exchangeRateState = exchangeRateState exchangeRateState = exchangeRateState
) )
) )
setMemoState(MemoState.new(it.memos?.firstOrNull().orEmpty())) 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 = { val onBackAction = {
when (sendStage) { when (sendStage) {
SendStage.Form -> goBack() 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]: 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]: 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 // TODO [#1146]: https://github.com/Electric-Coin-Company/zashi-android/issues/1146
@ -290,7 +275,6 @@ internal fun WrapSend(
} else { } else {
Send( Send(
balanceState = balanceState, balanceState = balanceState,
isHideBalances = isHideBalances,
sendStage = sendStage, sendStage = sendStage,
onCreateZecSend = { newZecSend -> onCreateZecSend = { newZecSend ->
viewModel.onCreateZecSendClick( viewModel.onCreateZecSendClick(
@ -299,6 +283,8 @@ internal fun WrapSend(
) )
}, },
onBack = onBackAction, onBack = onBackAction,
onQrScannerOpen = goToQrScanner,
hasCameraFeature = hasCameraFeature,
recipientAddressState = recipientAddressState, recipientAddressState = recipientAddressState,
onRecipientAddressChange = { onRecipientAddressChange = {
scope.launch { scope.launch {
@ -312,14 +298,11 @@ internal fun WrapSend(
) )
} }
}, },
memoState = memoState,
setMemoState = setMemoState,
amountState = amountState,
setAmountState = setAmountState, setAmountState = setAmountState,
onQrScannerOpen = goToQrScanner, amountState = amountState,
goBalances = goBalances, setMemoState = setMemoState,
hasCameraFeature = hasCameraFeature, memoState = memoState,
walletSnapshot = walletSnapshot, selectedAccount = selectedAccount,
exchangeRateState = exchangeRateState, exchangeRateState = exchangeRateState,
sendAddressBookState = sendAddressBookState, sendAddressBookState = sendAddressBookState,
zashiMainTopAppBarState = zashiMainTopAppBarState 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 cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.usecase.CreateProposalUseCase 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.NavigateToAddressBookUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase
@ -34,7 +33,6 @@ class SendViewModel(
private val observeContactByAddress: ObserveContactByAddressUseCase, private val observeContactByAddress: ObserveContactByAddressUseCase,
private val observeContactPicked: ObserveContactPickedUseCase, private val observeContactPicked: ObserveContactPickedUseCase,
private val createProposal: CreateProposalUseCase, private val createProposal: CreateProposalUseCase,
private val createKeystoneZip321TransactionProposal: CreateZip321ProposalUseCase,
private val observeWalletAccounts: ObserveWalletAccountsUseCase, private val observeWalletAccounts: ObserveWalletAccountsUseCase,
private val navigateToAddressBook: NavigateToAddressBookUseCase private val navigateToAddressBook: NavigateToAddressBookUseCase
) : ViewModel() { ) : ViewModel() {
@ -142,17 +140,4 @@ class SendViewModel(
Twig.error(e) { "Error creating proposal" } 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" }
}
}
} }

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