[#1079] Home screen bottom nav bar

* Changelog update

* [#1118] Account screen Balance Text line break

- Closes #1118

* [#1117] Reusable Loading screen indicator

- Closes #1117

* [#1116] Balances screen structure

- Closes #1116

* [#1079] Bottom bar - tabs navigation

- Closes #1079

* [#1079] Rework UI tests

* [#1079] File follow-ups

* [#1079] Fix static lint tools warnings

* [#1079] Improve Home sub-screens indexing

* [#1079] Reorg navigation into Home components

* [#1079] Align with Figma design

* [#1079] Update screenshot and UI tests
This commit is contained in:
Honza Rychnovský 2023-12-18 14:31:25 +01:00 committed by GitHub
parent 94616c4a7a
commit 4cf608b733
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1063 additions and 802 deletions

View File

@ -9,6 +9,9 @@ directly impact users rather than highlighting other key architectural updates.*
## [Unreleased]
### Changed
- Home screen navigation switched from the Side menu to the Bottom Navigation Tabs menu
## [0.2.0 (505)] - 2023-12-11
### Added

View File

@ -3,4 +3,5 @@ package co.electriccoin.zcash.ui.design.component
object CommonTag {
const val CHIP_LAYOUT = "chip_layout"
const val CHIP = "chip"
const val TOP_APP_BAR = "top_app_bar"
}

View File

@ -0,0 +1,36 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Preview
@Composable
private fun CircularScreenProgressIndicatorComposablePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
CircularScreenProgressIndicator()
}
}
}
@Composable
fun CircularScreenProgressIndicator(modifier: Modifier = Modifier) {
Box(
modifier =
Modifier
.fillMaxSize()
.then(modifier),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.width(ZcashTheme.dimens.circularScreenProgressWidth)
)
}
}

View File

@ -1,5 +1,9 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
@ -13,6 +17,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
@ -178,6 +183,7 @@ fun Reference(
* @param amount of ZECs to be displayed
* @param modifier to modify the Text UI element as needed
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HeaderWithZecIcon(
amount: String,
@ -187,7 +193,8 @@ fun HeaderWithZecIcon(
text = stringResource(R.string.amount_with_zec_currency_symbol, amount),
style = ZcashTheme.extendedTypography.zecBalance,
color = MaterialTheme.colorScheme.onBackground,
modifier = modifier
maxLines = 1,
modifier = Modifier.basicMarquee().then(modifier)
)
}
@ -203,3 +210,25 @@ fun BodyWithFiatCurrencySymbol(
modifier = modifier
)
}
@Composable
fun NavigationTabText(
text: String,
selected: Boolean,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = ZcashTheme.extendedTypography.textNavTab,
fontWeight =
if (selected) {
FontWeight.Black
} else {
FontWeight.Normal
},
maxLines = 1,
overflow = TextOverflow.Visible,
color = ZcashTheme.colors.tabTextColor,
modifier = modifier
)
}

View File

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CenterAlignedTopAppBar
@ -30,6 +31,7 @@ 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.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -111,6 +113,31 @@ private fun TopAppBarHamburgerMenuComposablePreview() {
}
}
@Preview
@Composable
private fun TopAppBarHamburgerPlusActionComposablePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
SmallTopAppBar(
titleText = "Screen E",
hamburgerMenuActions = {
TopBarHamburgerMenuExample(
actionCallback = {}
)
},
regularActions = {
IconButton(onClick = {}) {
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = "Content description text"
)
}
}
)
}
}
}
@Composable
private fun TopBarHamburgerMenuExample(
modifier: Modifier = Modifier,
@ -236,7 +263,13 @@ fun SmallTopAppBar(
}
}
},
actions = hamburgerMenuActions ?: regularActions ?: {},
modifier = modifier
actions = {
regularActions?.invoke(this)
hamburgerMenuActions?.invoke(this)
},
modifier =
Modifier
.testTag(CommonTag.TOP_APP_BAR)
.then(modifier)
)
}

View File

@ -28,6 +28,8 @@ data class Dimens(
// Chip
val chipShadowElevation: Dp,
val chipStroke: Dp,
// Progress
val circularScreenProgressWidth: Dp,
// TopAppBar:
val topAppBarZcashLogoHeight: Dp,
val topAppBarActionRippleCorner: Dp,
@ -59,6 +61,7 @@ private val defaultDimens =
buttonHeight = 50.dp,
chipShadowElevation = 4.dp,
chipStroke = 0.5.dp,
circularScreenProgressWidth = 48.dp,
topAppBarZcashLogoHeight = 24.dp,
topAppBarActionRippleCorner = 28.dp,
textFieldDefaultHeight = 215.dp,

View File

@ -35,6 +35,9 @@ data class ExtendedColors(
val screenTitleColor: Color,
val aboutTextColor: Color,
val welcomeAnimationColor: Color,
val complementaryColor: Color,
val dividerColor: Color,
val tabTextColor: Color,
) {
@Composable
fun surfaceGradient() =

View File

@ -11,6 +11,9 @@ import co.electriccoin.zcash.ui.design.theme.ExtendedColors
// TODO [#998]: Check and enhance screen dark mode
// TODO [#998]: https://github.com/Electric-Coin-Company/zashi-android/issues/998
// TODO [#1091]: Clear unused color resources
// TODO [#1091]: https://github.com/Electric-Coin-Company/zashi-android/issues/1091
internal object Dark {
val backgroundStart = Color(0xFF000000)
val backgroundEnd = Color(0xFF000000)
@ -65,12 +68,13 @@ internal object Dark {
val buttonShadowColor = Color(0xFFFFFFFF)
// to be added later
// Proper values will be added later, see #998
val aboutTextColor = Color.Unspecified
val screenTitleColor = Color(0xFF040404)
val welcomeAnimationColor = Color(0xFF231F20)
val complementaryColor = Color(0xFFF4B728)
val dividerColor = Color(0xFFDDDDDD)
val tabTextColor = Color(0xFF040404)
}
internal object Light {
@ -131,10 +135,11 @@ internal object Light {
val buttonShadowColor = Color(0xFF000000)
val screenTitleColor = Color(0xFF040404)
val aboutTextColor = Color(0xFF4E4E4E)
val welcomeAnimationColor = Color(0xFF231F20)
val complementaryColor = Color(0xFFF4B728)
val dividerColor = Color(0xFFDDDDDD)
val tabTextColor = Color(0xFF040404)
}
internal val DarkColorPalette =
@ -189,7 +194,10 @@ internal val DarkExtendedColorPalette =
buttonShadowColor = Dark.buttonShadowColor,
screenTitleColor = Dark.screenTitleColor,
aboutTextColor = Dark.aboutTextColor,
welcomeAnimationColor = Dark.welcomeAnimationColor
welcomeAnimationColor = Dark.welcomeAnimationColor,
complementaryColor = Dark.complementaryColor,
dividerColor = Dark.dividerColor,
tabTextColor = Dark.tabTextColor,
)
internal val LightExtendedColorPalette =
@ -220,7 +228,10 @@ internal val LightExtendedColorPalette =
buttonShadowColor = Light.buttonShadowColor,
screenTitleColor = Light.screenTitleColor,
aboutTextColor = Light.aboutTextColor,
welcomeAnimationColor = Light.welcomeAnimationColor
welcomeAnimationColor = Light.welcomeAnimationColor,
complementaryColor = Light.complementaryColor,
dividerColor = Light.dividerColor,
tabTextColor = Dark.tabTextColor,
)
@Suppress("CompositionLocalAllowlist")
@ -254,5 +265,8 @@ internal val LocalExtendedColors =
screenTitleColor = Color.Unspecified,
aboutTextColor = Color.Unspecified,
welcomeAnimationColor = Color.Unspecified,
complementaryColor = Color.Unspecified,
dividerColor = Color.Unspecified,
tabTextColor = Color.Unspecified
)
}

View File

@ -9,7 +9,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
import co.electriccoin.zcash.ui.design.R
@ -129,6 +128,12 @@ internal val SecondaryTypography =
fontFamily = ArchivoFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
labelMedium =
TextStyle(
fontFamily = ArchivoFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 12.sp
)
)
@ -140,7 +145,6 @@ data class Typography(
@Immutable
data class ExtendedTypography(
val chipIndex: TextStyle,
val listItem: TextStyle,
val zecBalance: TextStyle,
val aboutText: TextStyle,
@ -150,6 +154,7 @@ data class ExtendedTypography(
val textFieldHint: TextStyle,
val textFieldValue: TextStyle,
val textFieldBirthday: TextStyle,
val textNavTab: TextStyle,
)
@Suppress("CompositionLocalAllowlist")
@ -165,12 +170,6 @@ val LocalTypographies =
val LocalExtendedTypography =
staticCompositionLocalOf {
ExtendedTypography(
chipIndex =
PrimaryTypography.bodyLarge.copy(
fontSize = 10.sp,
baselineShift = BaselineShift.Superscript,
fontWeight = FontWeight.Bold
),
listItem =
PrimaryTypography.bodyLarge.copy(
fontSize = 24.sp
@ -209,5 +208,9 @@ val LocalExtendedTypography =
fontSize = 17.sp,
),
textFieldBirthday = SecondaryTypography.headlineMedium.copy(),
textNavTab =
SecondaryTypography.labelSmall.copy(
fontSize = 13.sp
),
)
}

View File

@ -31,11 +31,12 @@ android {
setOf(
"src/main/res/ui/about",
"src/main/res/ui/account",
"src/main/res/ui/new_wallet_recovery",
"src/main/res/ui/balances",
"src/main/res/ui/common",
"src/main/res/ui/export_data",
"src/main/res/ui/history",
"src/main/res/ui/home",
"src/main/res/ui/new_wallet_recovery",
"src/main/res/ui/onboarding",
"src/main/res/ui/receive",
"src/main/res/ui/request",

View File

@ -0,0 +1,69 @@
package co.electriccoin.zcash.ui.screen.account
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.account.view.Account
import java.util.concurrent.atomic.AtomicInteger
class AccountTestSetup(
private val composeTestRule: ComposeContentTestRule,
private val walletSnapshot: WalletSnapshot,
private val isShowFiatConversion: Boolean
) {
private val onSettingsCount = AtomicInteger(0)
private val onReceiveCount = AtomicInteger(0)
private val onSendCount = AtomicInteger(0)
private val onHistoryCount = AtomicInteger(0)
fun getOnSettingsCount(): Int {
composeTestRule.waitForIdle()
return onSettingsCount.get()
}
fun getOnReceiveCount(): Int {
composeTestRule.waitForIdle()
return onReceiveCount.get()
}
fun getOnSendCount(): Int {
composeTestRule.waitForIdle()
return onSendCount.get()
}
fun getOnHistoryCount(): Int {
composeTestRule.waitForIdle()
return onHistoryCount.get()
}
fun getWalletSnapshot(): WalletSnapshot {
composeTestRule.waitForIdle()
return walletSnapshot
}
@Composable
@Suppress("TestFunctionName")
fun DefaultContent() {
Account(
walletSnapshot,
isUpdateAvailable = false,
isKeepScreenOnDuringSync = false,
isFiatConversionEnabled = isShowFiatConversion,
goSettings = {
onSettingsCount.incrementAndGet()
},
goHistory = {
onHistoryCount.incrementAndGet()
},
)
}
fun setDefaultContent() {
composeTestRule.setContent {
ZcashTheme {
DefaultContent()
}
}
}
}

View File

@ -0,0 +1,68 @@
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 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.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.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
class AccountViewIntegrationTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
private fun newTestSetup(walletSnapshot: WalletSnapshot) =
AccountTestSetup(
composeTestRule,
walletSnapshot,
isShowFiatConversion = false
)
// 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 {
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(AccountTag.SINGLE_LINE_TEXT).also {
it.assertIsDisplayed()
it.assertWidthIsAtLeast(1.dp)
}
}
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.screen.home.model
package co.electriccoin.zcash.ui.screen.account.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.Synchronizer
@ -37,7 +37,7 @@ class WalletDisplayValuesTest {
assertNotNull(values)
assertEquals(1f, values.progress.decimal)
assertEquals(walletSnapshot.totalBalance().toZecString(), values.zecAmountText)
assertTrue(values.statusText.startsWith(getStringResource(R.string.home_status_syncing_catchup)))
assertTrue(values.statusText.startsWith(getStringResource(R.string.account_status_syncing_catchup)))
// 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)

View File

@ -0,0 +1,102 @@
package co.electriccoin.zcash.ui.screen.account.view
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
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.clickSettingsTopAppBarMenu
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
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.STATUS_VIEWS).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(AccountTag.FIAT_CONVERSION).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithText(getStringResource(R.string.account_button_history), ignoreCase = true).also {
it.assertIsDisplayed()
}
}
@Test
@MediumTest
fun hide_fiat_conversion() {
newTestSetup(isShowFiatConversion = false)
composeTestRule.onNodeWithTag(AccountTag.FIAT_CONVERSION).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun click_history_button() {
val testSetup = newTestSetup()
Assert.assertEquals(0, testSetup.getOnHistoryCount())
composeTestRule.clickHistory()
Assert.assertEquals(1, testSetup.getOnHistoryCount())
}
@Test
@MediumTest
fun hamburger_settings_test() {
val testSetup = newTestSetup()
Assert.assertEquals(0, testSetup.getOnReceiveCount())
composeTestRule.clickSettingsTopAppBarMenu()
Assert.assertEquals(1, testSetup.getOnSettingsCount())
}
private fun newTestSetup(
isShowFiatConversion: Boolean = true,
walletSnapshot: WalletSnapshot = WalletSnapshotFixture.new()
) = AccountTestSetup(
composeTestRule,
walletSnapshot = walletSnapshot,
isShowFiatConversion = isShowFiatConversion
).apply {
setDefaultContent()
}
}
private fun ComposeContentTestRule.clickHistory() {
onNodeWithText(getStringResource(R.string.home_button_history), ignoreCase = true).also {
it.performScrollTo()
it.performClick()
}
}

View File

@ -1,30 +1,19 @@
package co.electriccoin.zcash.ui.screen.home
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.home.view.Home
import java.util.concurrent.atomic.AtomicInteger
class HomeTestSetup(
private val composeTestRule: ComposeContentTestRule,
private val walletSnapshot: WalletSnapshot,
private val isShowFiatConversion: Boolean
) {
private val onSettingsCount = AtomicInteger(0)
private val onReceiveCount = AtomicInteger(0)
private val onAccountsCount = AtomicInteger(0)
private val onSendCount = AtomicInteger(0)
private val onHistoryCount = AtomicInteger(0)
private val onReceiveCount = AtomicInteger(0)
private val onBalancesCount = AtomicInteger(0)
fun getOnSettingsCount(): Int {
fun getOnAccountCount(): Int {
composeTestRule.waitForIdle()
return onSettingsCount.get()
}
fun getOnReceiveCount(): Int {
composeTestRule.waitForIdle()
return onReceiveCount.get()
return onAccountsCount.get()
}
fun getOnSendCount(): Int {
@ -32,37 +21,27 @@ class HomeTestSetup(
return onSendCount.get()
}
fun getOnHistoryCount(): Int {
fun getOnReceiveCount(): Int {
composeTestRule.waitForIdle()
return onHistoryCount.get()
return onReceiveCount.get()
}
fun getWalletSnapshot(): WalletSnapshot {
fun getOnBalancesCount(): Int {
composeTestRule.waitForIdle()
return walletSnapshot
return onBalancesCount.get()
}
// TODO [#1125]: Home screen navigation: Add integration test
// TODO [#1125]: https://github.com/Electric-Coin-Company/zashi-android/issues/1125
// TODO [#1126]: Home screen view: Add view test
// TODO [#1126]: https://github.com/Electric-Coin-Company/zashi-android/issues/1126
/*
@Composable
@Suppress("TestFunctionName")
fun DefaultContent() {
Home(
walletSnapshot,
isUpdateAvailable = false,
isKeepScreenOnDuringSync = false,
isFiatConversionEnabled = isShowFiatConversion,
goSettings = {
onSettingsCount.incrementAndGet()
},
goReceive = {
onReceiveCount.incrementAndGet()
},
goSend = {
onSendCount.incrementAndGet()
},
goHistory = {
onHistoryCount.incrementAndGet()
},
)
Home()
}
fun setDefaultContent() {
@ -72,4 +51,5 @@ class HomeTestSetup(
}
}
}
*/
}

View File

@ -1,28 +1,17 @@
package co.electriccoin.zcash.ui.screen.home.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 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.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.home.HomeTag
import co.electriccoin.zcash.ui.screen.home.HomeTestSetup
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
class HomeViewIntegrationTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
// TODO [#1125]: Home screen navigation: Add integration test
// TODO [#1125]: https://github.com/Electric-Coin-Company/zashi-android/issues/1125
/*
private fun newTestSetup(walletSnapshot: WalletSnapshot) =
HomeTestSetup(
composeTestRule,
@ -30,7 +19,6 @@ class HomeViewIntegrationTest : UiTestPrerequisites() {
isShowFiatConversion = false
)
// 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() {
@ -60,9 +48,10 @@ class HomeViewIntegrationTest : UiTestPrerequisites() {
assertNotEquals(WalletSnapshotFixture.PROGRESS, testSetup.getWalletSnapshot().progress)
assertEquals(0.5f, testSetup.getWalletSnapshot().progress.decimal)
composeTestRule.onNodeWithTag(HomeTag.SINGLE_LINE_TEXT).also {
composeTestRule.onNodeWithTag(AccountTag.SINGLE_LINE_TEXT).also {
it.assertIsDisplayed()
it.assertWidthIsAtLeast(1.dp)
}
}
*/
}

View File

@ -1,147 +1,40 @@
@file:Suppress("UnusedPrivateMember")
package co.electriccoin.zcash.ui.screen.home.view
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.home.HomeTag
import co.electriccoin.zcash.ui.screen.home.HomeTestSetup
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class HomeViewTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun check_all_elementary_ui_elements_displayed() {
newTestSetup()
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.home_menu_content_description)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(HomeTag.STATUS_VIEWS).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(HomeTag.FIAT_CONVERSION).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithText(getStringResource(R.string.home_button_send), ignoreCase = true).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithText(getStringResource(R.string.home_button_receive), ignoreCase = true).also {
it.assertIsDisplayed()
}
}
@Test
@MediumTest
fun hide_fiat_conversion() {
newTestSetup(isShowFiatConversion = false)
composeTestRule.onNodeWithTag(HomeTag.FIAT_CONVERSION).also {
it.assertDoesNotExist()
}
}
@Test
@MediumTest
fun click_receive_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnReceiveCount())
composeTestRule.clickReceive()
assertEquals(1, testSetup.getOnReceiveCount())
}
@Test
@MediumTest
fun click_send_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnSendCount())
composeTestRule.clickSend()
assertEquals(1, testSetup.getOnSendCount())
}
@Test
@MediumTest
fun click_history_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnHistoryCount())
composeTestRule.clickHistory()
assertEquals(1, testSetup.getOnHistoryCount())
}
@Test
@MediumTest
fun hamburger_settings() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnReceiveCount())
composeTestRule.openSettings()
assertEquals(1, testSetup.getOnSettingsCount())
}
private fun newTestSetup(
isShowFiatConversion: Boolean = true,
walletSnapshot: WalletSnapshot = WalletSnapshotFixture.new()
) = HomeTestSetup(
composeTestRule,
walletSnapshot = walletSnapshot,
isShowFiatConversion = isShowFiatConversion
).apply {
setDefaultContent()
}
class HomeViewTest {
// TODO [#1126]: Home screen view: Add view test
// TODO [#1126]: https://github.com/Electric-Coin-Company/zashi-android/issues/1126
}
private fun ComposeContentTestRule.openSettings() {
onNodeWithContentDescription(getStringResource(R.string.home_menu_content_description)).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickReceive() {
onNodeWithText(getStringResource(R.string.home_button_receive), ignoreCase = true).also {
private fun ComposeContentTestRule.clickAccount() {
onNodeWithText(getStringResource(R.string.home_tab_account), ignoreCase = true).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickSend() {
onNodeWithText(getStringResource(R.string.home_button_send), ignoreCase = true).also {
onNodeWithText(getStringResource(R.string.home_tab_send), ignoreCase = true).also {
it.performScrollTo()
it.performClick()
}
}
private fun ComposeContentTestRule.clickHistory() {
onNodeWithText(getStringResource(R.string.home_button_history), ignoreCase = true).also {
it.performScrollTo()
private fun ComposeContentTestRule.clickReceive() {
onNodeWithText(getStringResource(R.string.home_tab_receive), ignoreCase = true).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickBalances() {
onNodeWithText(getStringResource(R.string.home_tab_balances), ignoreCase = true).also {
it.performClick()
}
}

View File

@ -38,19 +38,19 @@ class ReceiveViewTest {
@Test
@MediumTest
fun back() =
fun click_settings_test() =
runTest {
val testSetup = newTestSetup(WalletAddressFixture.unified())
assertEquals(0, testSetup.getOnBackCount())
assertEquals(0, testSetup.getOnSettingsCount())
composeTestRule.onNodeWithContentDescription(
getStringResource(R.string.receive_back_content_description)
getStringResource(R.string.settings_menu_content_description)
).also {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
assertEquals(1, testSetup.getOnSettingsCount())
}
@Test

View File

@ -19,7 +19,7 @@ class ReceiveViewTestSetup(
private val composeTestRule: ComposeContentTestRule,
walletAddress: WalletAddress
) {
private val onBackCount = AtomicInteger(0)
private val onSettingsCount = AtomicInteger(0)
private val onAddressDetailsCount = AtomicInteger(0)
private val screenBrightness = ScreenBrightness()
private val screenTimeout = ScreenTimeout()
@ -34,9 +34,9 @@ class ReceiveViewTestSetup(
return onAdjustBrightness.get()
}
fun getOnBackCount(): Int {
fun getOnSettingsCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
return onSettingsCount.get()
}
fun getOnAddressDetailsCount(): Int {
@ -54,8 +54,8 @@ class ReceiveViewTestSetup(
ZcashTheme {
Receive(
walletAddress,
onBack = {
onBackCount.getAndIncrement()
onSettings = {
onSettingsCount.getAndIncrement()
},
onAddressDetails = {
onAddressDetailsCount.getAndIncrement()

View File

@ -4,6 +4,7 @@ import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
@ -20,6 +21,12 @@ internal fun ComposeContentTestRule.clickBack() {
}
}
internal fun ComposeContentTestRule.clickSettingsTopAppBarMenu() {
onNodeWithContentDescription(getStringResource(R.string.settings_menu_content_description)).also {
it.performClick()
}
}
internal fun ComposeContentTestRule.clickScanner() {
onNodeWithContentDescription(getStringResource(R.string.send_scan_content_description)).also {
it.performClick()
@ -70,25 +77,25 @@ internal fun ComposeContentTestRule.setMemo(memo: String) {
}
internal fun ComposeContentTestRule.clickCreateAndSend() {
onNodeWithText(getStringResource(R.string.send_create), ignoreCase = true).also {
onNodeWithTag(SendTag.SEND_FORM_BUTTON).also {
it.performClick()
}
}
internal fun ComposeContentTestRule.clickConfirmation() {
onNodeWithText(getStringResource(R.string.send_confirmation_button), ignoreCase = true).also {
onNodeWithTag(SendTag.SEND_CONFIRMATION_BUTTON).also {
it.performClick()
}
}
internal fun ComposeContentTestRule.assertOnForm() {
onNodeWithText(getStringResource(R.string.send_create), ignoreCase = true).also {
onNodeWithTag(SendTag.SEND_FORM_BUTTON).also {
it.assertExists()
}
}
internal fun ComposeContentTestRule.assertOnConfirmation() {
onNodeWithText(getStringResource(R.string.send_confirmation_button), ignoreCase = true).also {
onNodeWithTag(SendTag.SEND_CONFIRMATION_BUTTON).also {
it.assertExists()
}
}
@ -112,13 +119,13 @@ internal fun ComposeContentTestRule.assertOnSendFailure() {
}
internal fun ComposeContentTestRule.assertSendEnabled() {
onNodeWithText(getStringResource(R.string.send_create), ignoreCase = true).also {
onNodeWithTag(SendTag.SEND_FORM_BUTTON).also {
it.assertIsEnabled()
}
}
internal fun ComposeContentTestRule.assertSendDisabled() {
onNodeWithText(getStringResource(R.string.send_create), ignoreCase = true).also {
onNodeWithTag(SendTag.SEND_FORM_BUTTON).also {
it.assertIsNotEnabled()
}
}

View File

@ -24,6 +24,7 @@ class SendViewTestSetup(
private val hasCameraFeature: Boolean
) {
private val onBackCount = AtomicInteger(0)
private val onSettingsCount = AtomicInteger(0)
private val onCreateCount = AtomicInteger(0)
private val onScannerCount = AtomicInteger(0)
val mutableActionExecuted = MutableStateFlow(false)
@ -39,6 +40,11 @@ class SendViewTestSetup(
return onBackCount.get()
}
fun getOnSettingsCount(): Int {
composeTestRule.waitForIdle()
return onSettingsCount.get()
}
fun getOnCreateCount(): Int {
composeTestRule.waitForIdle()
return onCreateCount.get()
@ -96,6 +102,7 @@ class SendViewTestSetup(
zecSend = zecSend,
onZecSendChange = setZecSend,
onBack = onBackAction,
onSettings = { onSettingsCount.incrementAndGet() },
onCreateAndSend = {
onCreateCount.incrementAndGet()
lastZecSend = it

View File

@ -57,7 +57,8 @@ class SendViewIntegrationTest {
spendingKey = spendingKey,
goToQrScanner = {},
goBack = {},
hasCameraFeature = true
hasCameraFeature = true,
goSettings = {}
)
}

View File

@ -4,6 +4,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
@ -18,6 +19,8 @@ import cash.z.ecc.sdk.fixture.ZecSendFixture
import co.electriccoin.zcash.test.UiTestPrerequisites
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.SEND_FAILED_BUTTON
import co.electriccoin.zcash.ui.screen.send.SendViewTestSetup
import co.electriccoin.zcash.ui.screen.send.assertOnConfirmation
import co.electriccoin.zcash.ui.screen.send.assertOnForm
@ -30,6 +33,7 @@ import co.electriccoin.zcash.ui.screen.send.clickBack
import co.electriccoin.zcash.ui.screen.send.clickConfirmation
import co.electriccoin.zcash.ui.screen.send.clickCreateAndSend
import co.electriccoin.zcash.ui.screen.send.clickScanner
import co.electriccoin.zcash.ui.screen.send.clickSettingsTopAppBarMenu
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage
import co.electriccoin.zcash.ui.screen.send.setAmount
@ -38,7 +42,6 @@ import co.electriccoin.zcash.ui.screen.send.setValidAddress
import co.electriccoin.zcash.ui.screen.send.setValidAmount
import co.electriccoin.zcash.ui.screen.send.setValidMemo
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -74,7 +77,7 @@ class SendViewTest : UiTestPrerequisites() {
@Suppress("UNUSED_VARIABLE")
val testSetup = newTestSetup()
composeTestRule.onNodeWithText(getStringResource(R.string.send_create), ignoreCase = true).also {
composeTestRule.onNodeWithTag(SendTag.SEND_FORM_BUTTON).also {
it.assertExists()
it.assertIsNotEnabled()
}
@ -82,7 +85,6 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun create_request_no_memo() =
runTest {
val testSetup = newTestSetup()
@ -117,7 +119,6 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun create_request_with_memo() =
runTest {
val testSetup = newTestSetup()
@ -154,7 +155,6 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun check_regex_functionality_valid_inputs() =
runTest {
val testSetup = newTestSetup()
@ -213,7 +213,6 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun check_regex_functionality_invalid_inputs() =
runTest {
val testSetup = newTestSetup()
@ -252,7 +251,6 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun max_memo_length() =
runTest {
val testSetup = newTestSetup()
@ -294,14 +292,14 @@ class SendViewTest : UiTestPrerequisites() {
@Test
@MediumTest
fun back_on_form() {
fun on_settings_click_test() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
assertEquals(0, testSetup.getOnSettingsCount())
composeTestRule.clickBack()
composeTestRule.clickSettingsTopAppBarMenu()
assertEquals(1, testSetup.getOnBackCount())
assertEquals(1, testSetup.getOnSettingsCount())
}
@Test
@ -405,7 +403,7 @@ class SendViewTest : UiTestPrerequisites() {
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.assertOnSendFailure()
composeTestRule.onNodeWithText(getStringResource(R.string.send_failure_button), ignoreCase = true).also {
composeTestRule.onNodeWithTag(SEND_FAILED_BUTTON).also {
it.assertExists()
it.performClick()
}

View File

@ -1,6 +1,5 @@
package co.electriccoin.zcash.ui
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@ -9,7 +8,6 @@ import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.NavigationArguments.SEND_AMOUNT
import co.electriccoin.zcash.ui.NavigationArguments.SEND_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.SEND_RECIPIENT_ADDRESS
@ -17,26 +15,22 @@ import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HISTORY
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.RECEIVE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
import co.electriccoin.zcash.ui.NavigationTargets.SCAN
import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY
import co.electriccoin.zcash.ui.NavigationTargets.SEND
import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.NavigationTargets.WALLET_ADDRESS_DETAILS
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.account.WrapAccount
import co.electriccoin.zcash.ui.screen.address.WrapWalletAddresses
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.history.WrapHistory
import co.electriccoin.zcash.ui.screen.receive.WrapReceive
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.request.WrapRequest
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
import co.electriccoin.zcash.ui.screen.seedrecovery.WrapSeedRecovery
import co.electriccoin.zcash.ui.screen.send.WrapSend
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.settings.WrapSettings
import co.electriccoin.zcash.ui.screen.support.WrapSupport
@ -48,19 +42,31 @@ internal fun MainActivity.Navigation() {
val context = LocalContext.current
val navController =
rememberNavController().also {
// This suppress is necessary, as this is how we set up the nav controller for tests.
@SuppressLint("RestrictedApi")
navControllerForTesting = it
}
NavHost(navController = navController, startDestination = HOME) {
composable(HOME) {
WrapAccount(
composable(HOME) { backStackEntry ->
WrapHome(
onPageChange = {
homeViewModel.screenIndex.value = it
},
goAddressDetails = { navController.navigateJustOnce(WALLET_ADDRESS_DETAILS) },
goBack = { finish() },
goHistory = { navController.navigateJustOnce(HISTORY) },
goReceive = { navController.navigateJustOnce(RECEIVE) },
goSend = { navController.navigateJustOnce(SEND) },
goSettings = { navController.navigateJustOnce(SETTINGS) },
goScan = { navController.navigateJustOnce(SCAN) },
sendArgumentsWrapper =
SendArgumentsWrapper(
recipientAddress = backStackEntry.savedStateHandle[SEND_RECIPIENT_ADDRESS],
amount = backStackEntry.savedStateHandle[SEND_AMOUNT],
memo = backStackEntry.savedStateHandle[SEND_MEMO]
),
)
// Remove used Send screen parameters passed from the Scan screen if some exist
backStackEntry.savedStateHandle.remove<String>(SEND_RECIPIENT_ADDRESS)
backStackEntry.savedStateHandle.remove<String>(SEND_AMOUNT)
backStackEntry.savedStateHandle.remove<String>(SEND_MEMO)
if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
WrapCheckForUpdate()
@ -112,33 +118,9 @@ internal fun MainActivity.Navigation() {
}
)
}
composable(RECEIVE) {
WrapReceive(
onBack = { navController.popBackStackJustOnce(RECEIVE) },
onAddressDetails = { navController.navigateJustOnce(WALLET_ADDRESS_DETAILS) }
)
}
composable(REQUEST) {
WrapRequest(goBack = { navController.popBackStackJustOnce(REQUEST) })
}
composable(SEND) { backStackEntry ->
WrapSend(
goToQrScanner = {
Twig.debug { "Opening Qr Scanner Screen" }
navController.navigateJustOnce(SCAN)
},
goBack = { navController.popBackStackJustOnce(SEND) },
sendArgumentsWrapper =
SendArgumentsWrapper(
recipientAddress = backStackEntry.savedStateHandle[SEND_RECIPIENT_ADDRESS],
amount = backStackEntry.savedStateHandle[SEND_AMOUNT],
memo = backStackEntry.savedStateHandle[SEND_MEMO]
)
)
backStackEntry.savedStateHandle.remove<String>(SEND_RECIPIENT_ADDRESS)
backStackEntry.savedStateHandle.remove<String>(SEND_AMOUNT)
backStackEntry.savedStateHandle.remove<String>(SEND_MEMO)
}
composable(SUPPORT) {
// Pop back stack won't be right if we deep link into support
WrapSupport(goBack = { navController.popBackStackJustOnce(SUPPORT) })
@ -208,6 +190,7 @@ object NavigationArguments {
object NavigationTargets {
const val ABOUT = "about"
const val ACCOUNT = "account"
const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HISTORY = "history"
const val HOME = "home"

View File

@ -5,4 +5,5 @@ package co.electriccoin.zcash.ui.common.test
*/
object CommonTag {
const val WALLET_BIRTHDAY = "wallet_birthday"
const val SETTINGS_TOP_BAR_BUTTON = "settings_top_bar_button"
}

View File

@ -8,6 +8,8 @@ import co.electriccoin.zcash.configuration.model.map.Configuration
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
@ -15,8 +17,14 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
class HomeViewModel(application: Application) : AndroidViewModel(application) {
/**
* Current Home sub-screen index in flow.
*/
val screenIndex: MutableStateFlow<HomeScreenIndex> = MutableStateFlow(HomeScreenIndex.ACCOUNT)
/**
* A flow of whether background sync is enabled
* Current Home sub-screen index in flow.
*/
val isBackgroundSyncEnabled: StateFlow<Boolean?> =
flow {

View File

@ -57,7 +57,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
@ -151,7 +150,7 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
null
)
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
@OptIn(ExperimentalCoroutinesApi::class)
val walletSnapshot: StateFlow<WalletSnapshot?> =
synchronizer
.flatMapLatest {

View File

@ -7,5 +7,4 @@ object AccountTag {
const val STATUS_VIEWS = "status_views"
const val SINGLE_LINE_TEXT = "single_line_text"
const val FIAT_CONVERSION = "fiat_conversion"
const val SETTINGS_TOP_BAR_BUTTON = "settings_top_bar_button"
}

View File

@ -6,43 +6,23 @@ import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.common.viewmodel.CheckUpdateViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.account.view.Account
import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerImp
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
@Composable
@Suppress("LongParameterList")
internal fun MainActivity.WrapAccount(
goSettings: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit
) {
WrapAccount(
this,
goSettings = goSettings,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory,
)
}
@Composable
@Suppress("LongParameterList")
internal fun WrapAccount(
activity: ComponentActivity,
goSettings: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit,
) {
// we want to show information about app update, if available
// Show information about the app update, if available
val checkUpdateViewModel by activity.viewModels<CheckUpdateViewModel> {
CheckUpdateViewModel.CheckUpdateViewModelFactory(
activity.application,
@ -63,7 +43,8 @@ internal fun WrapAccount(
val isFiatConversionEnabled = ConfigurationEntries.IS_FIAT_CONVERSION_ENABLED.getValue(RemoteConfig.current)
if (null == walletSnapshot) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
Account(
walletSnapshot = walletSnapshot,
@ -71,11 +52,10 @@ internal fun WrapAccount(
isKeepScreenOnDuringSync = isKeepScreenOnWhileSyncing,
isFiatConversionEnabled = isFiatConversionEnabled,
goSettings = goSettings,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory
)
// For benchmarking purposes
activity.reportFullyDrawn()
}
}

View File

@ -24,6 +24,7 @@ import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BodyWithFiatCurrencySymbol
@ -31,7 +32,6 @@ import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.HeaderWithZecIcon
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.screen.account.AccountTag
@ -48,8 +48,6 @@ private fun ComposablePreview() {
isKeepScreenOnDuringSync = false,
isFiatConversionEnabled = false,
goSettings = {},
goReceive = {},
goSend = {},
goHistory = {}
)
}
@ -64,8 +62,6 @@ fun Account(
isKeepScreenOnDuringSync: Boolean?,
isFiatConversionEnabled: Boolean,
goSettings: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit
) {
Scaffold(topBar = {
@ -76,8 +72,6 @@ fun Account(
isUpdateAvailable = isUpdateAvailable,
isKeepScreenOnDuringSync = isKeepScreenOnDuringSync,
isFiatConversionEnabled = isFiatConversionEnabled,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory,
modifier =
Modifier.padding(
@ -97,11 +91,11 @@ private fun AccountTopAppBar(onSettings: () -> Unit) {
hamburgerMenuActions = {
IconButton(
onClick = onSettings,
modifier = Modifier.testTag(AccountTag.SETTINGS_TOP_BAR_BUTTON)
modifier = Modifier.testTag(CommonTag.SETTINGS_TOP_BAR_BUTTON)
) {
Icon(
painter = painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.hamburger_menu_icon),
contentDescription = stringResource(id = R.string.account_menu_content_description)
contentDescription = stringResource(id = R.string.settings_menu_content_description)
)
}
}
@ -115,8 +109,6 @@ private fun AccountMainContent(
isUpdateAvailable: Boolean,
isKeepScreenOnDuringSync: Boolean?,
isFiatConversionEnabled: Boolean,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit,
modifier: Modifier = Modifier
) {
@ -142,21 +134,7 @@ private fun AccountMainContent(
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
PrimaryButton(
onClick = goSend,
text = stringResource(R.string.account_button_send)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
PrimaryButton(
onClick = goReceive,
text = stringResource(R.string.account_button_receive)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
TertiaryButton(onClick = goHistory, text = stringResource(R.string.account_button_history))
PrimaryButton(onClick = goHistory, text = stringResource(R.string.account_button_history))
if (isKeepScreenOnDuringSync == true && walletSnapshot.status == Synchronizer.Status.SYNCING) {
DisableScreenTimeout()

View File

@ -10,6 +10,7 @@ import co.electriccoin.zcash.spackle.ClipboardManagerUtil
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.address.view.WalletAddresses
@Composable
@ -27,7 +28,8 @@ private fun WrapWalletAddresses(
val walletAddresses = walletViewModel.addresses.collectAsStateWithLifecycle().value
if (null == walletAddresses) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
WalletAddresses(
walletAddresses,

View File

@ -0,0 +1,18 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.balances
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import co.electriccoin.zcash.ui.screen.balances.view.Balances
@Suppress("UNUSED_PARAMETER")
@Composable
internal fun WrapBalances(
activity: ComponentActivity,
goSettings: () -> Unit,
) {
Balances(
onSettings = goSettings,
)
}

View File

@ -0,0 +1,89 @@
package co.electriccoin.zcash.ui.screen.balances.view
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Preview("Balances")
@Composable
private fun ComposablePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Balances(
onSettings = {},
)
}
}
}
// TODO [#1127]: Implement Balances screen
// TODO [#1127]: https://github.com/Electric-Coin-Company/zashi-android/issues/1127
@Composable
fun Balances(onSettings: () -> Unit) {
Scaffold(topBar = {
BalancesTopAppBar(onSettings = onSettings)
}) { paddingValues ->
BalancesMainContent(
modifier =
Modifier.padding(
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingHuge,
start = ZcashTheme.dimens.screenHorizontalSpacing,
end = ZcashTheme.dimens.screenHorizontalSpacing
)
)
}
}
@Composable
private fun BalancesTopAppBar(onSettings: () -> Unit) {
SmallTopAppBar(
showTitleLogo = true,
hamburgerMenuActions = {
IconButton(
onClick = onSettings,
modifier = Modifier.testTag(CommonTag.SETTINGS_TOP_BAR_BUTTON)
) {
Icon(
painter = painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.hamburger_menu_icon),
contentDescription = stringResource(id = R.string.settings_menu_content_description)
)
}
}
)
}
@Composable
private fun BalancesMainContent(modifier: Modifier = Modifier) {
Box(
Modifier
.fillMaxSize()
.then(modifier),
contentAlignment = Alignment.Center
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Body(stringResource(id = R.string.not_implemented_yet))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}

View File

@ -16,6 +16,7 @@ import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.VersionInfo
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.exportdata.util.FileShareUtil
import co.electriccoin.zcash.ui.screen.exportdata.view.ExportPrivateData
import kotlinx.coroutines.channels.awaitClose
@ -45,7 +46,8 @@ internal fun WrapExportPrivateData(
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
if (synchronizer == null) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()

View File

@ -3,79 +3,162 @@
package co.electriccoin.zcash.ui.screen.home
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.stringResource
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.common.viewmodel.CheckUpdateViewModel
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.HomeViewModel
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.settings.viewmodel.SettingsViewModel
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerImp
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.receive.WrapReceive
import co.electriccoin.zcash.ui.screen.send.WrapSend
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@Composable
@Suppress("LongParameterList")
@Composable
internal fun MainActivity.WrapHome(
onPageChange: (HomeScreenIndex) -> Unit,
goAddressDetails: () -> Unit,
goBack: () -> Unit,
goHistory: () -> Unit,
goSettings: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit
goScan: () -> Unit,
sendArgumentsWrapper: SendArgumentsWrapper
) {
WrapHome(
this,
goSettings = goSettings,
goReceive = goReceive,
goSend = goSend,
onPageChange = onPageChange,
goAddressDetails = goAddressDetails,
goBack = goBack,
goHistory = goHistory,
goScan = goScan,
goSettings = goSettings,
sendArgumentsWrapper = sendArgumentsWrapper
)
}
@Suppress("LongParameterList", "LongMethod")
@Composable
@Suppress("LongParameterList")
internal fun WrapHome(
activity: ComponentActivity,
goSettings: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goAddressDetails: () -> Unit,
goBack: () -> Unit,
goHistory: () -> Unit,
goSettings: () -> Unit,
goScan: () -> Unit,
onPageChange: (HomeScreenIndex) -> Unit,
sendArgumentsWrapper: SendArgumentsWrapper
) {
// we want to show information about app update, if available
val checkUpdateViewModel by activity.viewModels<CheckUpdateViewModel> {
CheckUpdateViewModel.CheckUpdateViewModelFactory(
activity.application,
AppUpdateCheckerImp.new()
val homeViewModel by activity.viewModels<HomeViewModel>()
// Flow for propagating the new page index to the pager in the view layer
val forceHomePageIndexFlow: MutableSharedFlow<ForcePage?> =
MutableSharedFlow(
Int.MAX_VALUE,
Int.MAX_VALUE,
BufferOverflow.SUSPEND
)
}
val updateAvailable =
checkUpdateViewModel.updateInfo.collectAsStateWithLifecycle().value.let {
it?.appUpdateInfo != null && it.state == UpdateState.Prepared
val forceIndex = forceHomePageIndexFlow.collectAsState(initial = null).value
val homeGoBack: () -> Unit = {
when (homeViewModel.screenIndex.value) {
HomeScreenIndex.ACCOUNT -> goBack()
HomeScreenIndex.SEND,
HomeScreenIndex.RECEIVE,
HomeScreenIndex.BALANCES -> forceHomePageIndexFlow.tryEmit(ForcePage())
}
}
val walletViewModel by activity.viewModels<WalletViewModel>()
val walletSnapshot = walletViewModel.walletSnapshot.collectAsStateWithLifecycle().value
BackHandler {
homeGoBack()
}
val settingsViewModel by activity.viewModels<SettingsViewModel>()
val isKeepScreenOnWhileSyncing = settingsViewModel.isKeepScreenOnWhileSyncing.collectAsStateWithLifecycle().value
val isFiatConversionEnabled = ConfigurationEntries.IS_FIAT_CONVERSION_ENABLED.getValue(RemoteConfig.current)
if (null == walletSnapshot) {
// Display loading indicator
} else {
Home(
walletSnapshot = walletSnapshot,
isUpdateAvailable = updateAvailable,
isKeepScreenOnDuringSync = isKeepScreenOnWhileSyncing,
isFiatConversionEnabled = isFiatConversionEnabled,
goSettings = goSettings,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory
val tabs =
persistentListOf(
TabItem(
index = HomeScreenIndex.ACCOUNT,
title = stringResource(id = R.string.home_tab_account),
testTag = HomeTag.TAB_ACCOUNT,
screenContent = {
WrapAccount(
activity = activity,
goHistory = goHistory,
goSettings = goSettings,
)
}
),
TabItem(
index = HomeScreenIndex.SEND,
title = stringResource(id = R.string.home_tab_send),
testTag = HomeTag.TAB_SEND,
screenContent = {
WrapSend(
activity = activity,
goToQrScanner = goScan,
goBack = homeGoBack,
goSettings = goSettings,
sendArgumentsWrapper = sendArgumentsWrapper
)
}
),
TabItem(
index = HomeScreenIndex.RECEIVE,
title = stringResource(id = R.string.home_tab_receive),
testTag = HomeTag.TAB_RECEIVE,
screenContent = {
WrapReceive(
activity = activity,
onSettings = goSettings,
onAddressDetails = goAddressDetails,
)
}
),
TabItem(
index = HomeScreenIndex.BALANCES,
title = stringResource(id = R.string.home_tab_balances),
testTag = HomeTag.TAB_BALANCES,
screenContent = {
WrapBalances(
activity = activity,
goSettings = goSettings
)
}
)
)
activity.reportFullyDrawn()
Home(
subScreens = tabs,
forcePage = forceIndex,
onPageChange = onPageChange
)
}
/**
* Wrapper class used to pass forced pages index into the view layer
*/
class ForcePage(
val currentPage: HomeScreenIndex = HomeScreenIndex.ACCOUNT,
)
/**
* Enum of the Home screen sub-screens
*/
enum class HomeScreenIndex {
// WARN: Be careful when re-ordering these, as the ordinal number states for their order
ACCOUNT,
SEND,
RECEIVE,
BALANCES, ;
companion object {
fun fromIndex(index: Int) = entries[index]
}
}

View File

@ -4,8 +4,8 @@ package co.electriccoin.zcash.ui.screen.home
* These are only used for automated testing.
*/
object HomeTag {
const val STATUS_VIEWS = "status_views"
const val SINGLE_LINE_TEXT = "single_line_text"
const val FIAT_CONVERSION = "fiat_conversion"
const val SETTINGS_TOP_BAR_BUTTON = "settings_top_bar_button"
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,11 @@
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,117 +0,0 @@
package co.electriccoin.zcash.ui.screen.home.model
import android.content.Context
import androidx.compose.ui.text.intl.Locale
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import cash.z.ecc.android.sdk.model.MonetarySeparators
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 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.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.model.totalBalance
import co.electriccoin.zcash.ui.common.toKotlinLocale
data class WalletDisplayValues(
val progress: PercentDecimal,
val zecAmountText: String,
val statusText: String,
val fiatCurrencyAmountState: FiatCurrencyConversionRateState,
val fiatCurrencyAmountText: String
) {
companion object {
@Suppress("MagicNumber", "LongMethod")
internal fun getNextValues(
context: Context,
walletSnapshot: WalletSnapshot,
updateAvailable: Boolean
): WalletDisplayValues {
var progress = PercentDecimal.ZERO_PERCENT
val zecAmountText = walletSnapshot.totalBalance().toZecString()
var statusText = ""
// TODO [#578]: Provide Zatoshi -> USD fiat currency formatting
// TODO [#578]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/578
// We'll ideally provide a "fresh" currencyConversion object here
val fiatCurrencyAmountState =
walletSnapshot.spendableBalance().toFiatCurrencyState(
null,
Locale.current.toKotlinLocale(),
MonetarySeparators.current()
)
var fiatCurrencyAmountText = getFiatCurrencyRateValue(context, fiatCurrencyAmountState)
when (walletSnapshot.status) {
Synchronizer.Status.SYNCING -> {
progress = walletSnapshot.progress
// we add "so far" to the amount
if (fiatCurrencyAmountState != FiatCurrencyConversionRateState.Unavailable) {
fiatCurrencyAmountText =
context.getString(
R.string.home_status_syncing_amount_suffix,
fiatCurrencyAmountText
)
}
statusText =
context.getString(
R.string.home_status_syncing_format,
walletSnapshot.progress.toPercentageWithDecimal()
)
}
Synchronizer.Status.SYNCED -> {
statusText =
if (updateAvailable) {
context.getString(R.string.home_status_update)
} else {
context.getString(R.string.home_status_up_to_date)
}
}
Synchronizer.Status.DISCONNECTED -> {
statusText =
context.getString(
R.string.home_status_error,
context.getString(R.string.home_status_error_connection)
)
}
Synchronizer.Status.STOPPED -> {
statusText = context.getString(R.string.home_status_stopped)
}
}
// more detailed error message
walletSnapshot.synchronizerError?.let {
statusText =
context.getString(
R.string.home_status_error,
walletSnapshot.synchronizerError.getCauseMessage()
?: context.getString(R.string.home_status_error_unknown)
)
}
return WalletDisplayValues(
progress = progress,
zecAmountText = zecAmountText,
statusText = statusText,
fiatCurrencyAmountState = fiatCurrencyAmountState,
fiatCurrencyAmountText = fiatCurrencyAmountText
)
}
}
}
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,43 +1,40 @@
@file:Suppress("TooManyFunctions")
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Divider
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.FiatCurrencyConversionRateState
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.BodyWithFiatCurrencySymbol
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.HeaderWithZecIcon
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.TertiaryButton
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.HomeTag
import co.electriccoin.zcash.ui.screen.home.model.WalletDisplayValues
import co.electriccoin.zcash.ui.screen.home.ForcePage
import co.electriccoin.zcash.ui.screen.home.HomeScreenIndex
import co.electriccoin.zcash.ui.screen.home.model.TabItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Preview("Home")
@Composable
@ -45,184 +42,115 @@ private fun ComposablePreview() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Home(
walletSnapshot = WalletSnapshotFixture.new(),
isUpdateAvailable = false,
isKeepScreenOnDuringSync = false,
isFiatConversionEnabled = false,
goSettings = {},
goReceive = {},
goSend = {},
goHistory = {}
subScreens = persistentListOf(),
forcePage = null,
onPageChange = {}
)
}
}
}
@Suppress("LongParameterList")
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
fun Home(
walletSnapshot: WalletSnapshot,
isUpdateAvailable: Boolean,
isKeepScreenOnDuringSync: Boolean?,
isFiatConversionEnabled: Boolean,
goSettings: () -> Unit,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit
subScreens: ImmutableList<TabItem>,
forcePage: ForcePage?,
onPageChange: (HomeScreenIndex) -> Unit,
) {
Scaffold(topBar = {
HomeTopAppBar(onSettings = goSettings)
}) { paddingValues ->
HomeMainContent(
walletSnapshot = walletSnapshot,
isUpdateAvailable = isUpdateAvailable,
isKeepScreenOnDuringSync = isKeepScreenOnDuringSync,
isFiatConversionEnabled = isFiatConversionEnabled,
goReceive = goReceive,
goSend = goSend,
goHistory = goHistory,
modifier =
Modifier.padding(
top = paddingValues.calculateTopPadding() + ZcashTheme.dimens.spacingDefault,
bottom = paddingValues.calculateBottomPadding() + ZcashTheme.dimens.spacingHuge,
start = ZcashTheme.dimens.screenHorizontalSpacing,
end = ZcashTheme.dimens.screenHorizontalSpacing
)
val pagerState =
rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f,
pageCount = { subScreens.size }
)
}
}
@Composable
private fun HomeTopAppBar(onSettings: () -> Unit) {
SmallTopAppBar(
showTitleLogo = true,
hamburgerMenuActions = {
IconButton(
onClick = onSettings,
modifier = Modifier.testTag(HomeTag.SETTINGS_TOP_BAR_BUTTON)
) {
Icon(
painter = painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.hamburger_menu_icon),
contentDescription = stringResource(id = R.string.home_menu_content_description)
)
// Listening for the current page change
LaunchedEffect(pagerState) {
snapshotFlow {
pagerState.currentPage
}.distinctUntilChanged()
.collect { page ->
Twig.info { "Current pager page: $page" }
onPageChange(HomeScreenIndex.fromIndex(page))
}
}
)
}
}
@Suppress("LongParameterList")
@Composable
private fun HomeMainContent(
walletSnapshot: WalletSnapshot,
isUpdateAvailable: Boolean,
isKeepScreenOnDuringSync: Boolean?,
isFiatConversionEnabled: Boolean,
goReceive: () -> Unit,
goSend: () -> Unit,
goHistory: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
Modifier
.fillMaxHeight()
.verticalScroll(
rememberScrollState()
)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Status(walletSnapshot, isUpdateAvailable, isFiatConversionEnabled)
Spacer(
modifier =
Modifier
.fillMaxHeight()
.weight(MINIMAL_WEIGHT)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
PrimaryButton(
onClick = goSend,
text = stringResource(R.string.home_button_send)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
PrimaryButton(
onClick = goReceive,
text = stringResource(R.string.home_button_receive)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
TertiaryButton(onClick = goHistory, text = stringResource(R.string.home_button_history))
if (isKeepScreenOnDuringSync == true && walletSnapshot.status == Synchronizer.Status.SYNCING) {
DisableScreenTimeout()
// Force page change e.g. when system back navigation event detected
forcePage?.let {
LaunchedEffect(forcePage) {
pagerState.animateScrollToPage(forcePage.currentPage.ordinal)
}
}
}
@Composable
@Suppress("LongMethod", "MagicNumber")
private fun Status(
walletSnapshot: WalletSnapshot,
updateAvailable: Boolean,
isFiatConversionEnabled: Boolean
) {
val walletDisplayValues =
WalletDisplayValues.getNextValues(
LocalContext.current,
walletSnapshot,
updateAvailable
)
val coroutineScope = rememberCoroutineScope()
Column(
modifier =
Modifier
.fillMaxWidth()
.testTag(HomeTag.STATUS_VIEWS),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
if (walletDisplayValues.zecAmountText.isNotEmpty()) {
HeaderWithZecIcon(amount = walletDisplayValues.zecAmountText)
}
if (isFiatConversionEnabled) {
Column(Modifier.testTag(HomeTag.FIAT_CONVERSION)) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
when (walletDisplayValues.fiatCurrencyAmountState) {
is FiatCurrencyConversionRateState.Current -> {
BodyWithFiatCurrencySymbol(
amount = walletDisplayValues.fiatCurrencyAmountText
Scaffold(
bottomBar = {
Column {
Divider(
thickness = DividerDefaults.Thickness,
color = ZcashTheme.colors.dividerColor
)
TabRow(
selectedTabIndex = pagerState.currentPage,
// Don't use the predefined divider, as it's fixed position is below the tabs bar
divider = {},
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier =
Modifier
.tabIndicatorOffset(tabPositions[pagerState.currentPage])
.padding(horizontal = ZcashTheme.dimens.spacingDefault),
color = ZcashTheme.colors.complementaryColor
)
}
is FiatCurrencyConversionRateState.Stale -> {
// Note: we should show information about staleness too
BodyWithFiatCurrencySymbol(
amount = walletDisplayValues.fiatCurrencyAmountText
},
modifier =
Modifier
.navigationBarsPadding()
.padding(all = ZcashTheme.dimens.spacingDefault)
) {
subScreens.forEachIndexed { index, item ->
val selected = index == pagerState.currentPage
Tab(
selected = selected,
text = {
NavigationTabText(
text = item.title,
selected = selected
)
},
modifier =
Modifier
.padding(all = 0.dp)
.testTag(item.testTag),
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
)
}
is FiatCurrencyConversionRateState.Unavailable -> {
Body(text = walletDisplayValues.fiatCurrencyAmountText)
}
}
}
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
if (walletDisplayValues.statusText.isNotEmpty()) {
Body(
text = walletDisplayValues.statusText,
modifier = Modifier.testTag(HomeTag.SINGLE_LINE_TEXT)
)
}
) { paddingValues ->
HorizontalPager(
state = pagerState,
pageSpacing = 0.dp,
pageSize = PageSize.Fill,
pageNestedScrollConnection =
PagerDefaults.pageNestedScrollConnection(
Orientation.Horizontal
),
pageContent = { index ->
subScreens[index].screenContent()
},
key = { index ->
subScreens[index].title
},
beyondBoundsPageCount = 1,
modifier =
Modifier
.padding(
bottom = paddingValues.calculateBottomPadding()
)
)
}
}

View File

@ -7,28 +7,14 @@ import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.model.WalletAddresses
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.receive.view.Receive
@Composable
@Suppress("LongParameterList")
internal fun MainActivity.WrapReceive(
onBack: () -> Unit,
onAddressDetails: () -> Unit,
) {
WrapReceive(
this,
onBack = onBack,
onAddressDetails = onAddressDetails,
)
}
@Composable
@Suppress("LongParameterList")
internal fun WrapReceive(
activity: ComponentActivity,
onBack: () -> Unit,
onSettings: () -> Unit,
onAddressDetails: () -> Unit,
) {
val viewModel by activity.viewModels<WalletViewModel>()
@ -36,24 +22,24 @@ internal fun WrapReceive(
WrapReceive(
walletAddresses,
onBack = onBack,
onSettings = onSettings,
onAddressDetails = onAddressDetails,
)
}
@Composable
@Suppress("LongParameterList")
internal fun WrapReceive(
walletAddresses: WalletAddresses?,
onBack: () -> Unit,
onSettings: () -> Unit,
onAddressDetails: () -> Unit,
) {
if (null == walletAddresses) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
Receive(
walletAddresses.unified,
onBack = onBack,
onSettings = onSettings,
onAddressDetails = onAddressDetails,
onAdjustBrightness = { /* Just for testing */ }
)

View File

@ -22,6 +22,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -32,6 +34,7 @@ import cash.z.ecc.android.sdk.model.WalletAddress
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.BrightenScreen
import co.electriccoin.zcash.ui.common.DisableScreenTimeout
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.GradientSurface
@ -50,7 +53,7 @@ private fun ComposablePreview() {
GradientSurface {
Receive(
walletAddress = runBlocking { WalletAddressFixture.unified() },
onBack = {},
onSettings = {},
onAddressDetails = {},
onAdjustBrightness = {},
)
@ -61,7 +64,7 @@ private fun ComposablePreview() {
@Composable
fun Receive(
walletAddress: WalletAddress,
onBack: () -> Unit,
onSettings: () -> Unit,
onAddressDetails: () -> Unit,
onAdjustBrightness: (Boolean) -> Unit,
) {
@ -71,7 +74,7 @@ fun Receive(
Column {
ReceiveTopAppBar(
adjustBrightness = brightness,
onBack = onBack,
onSettings = onSettings,
onBrightness = {
onAdjustBrightness(!brightness)
setBrightness(!brightness)
@ -97,14 +100,11 @@ fun Receive(
@Composable
private fun ReceiveTopAppBar(
adjustBrightness: Boolean,
onBack: () -> Unit,
onSettings: () -> Unit,
onBrightness: () -> Unit
) {
SmallTopAppBar(
titleText = stringResource(id = R.string.receive_title),
backText = stringResource(id = R.string.receive_back),
backContentDescriptionText = stringResource(id = R.string.receive_back_content_description),
onBack = onBack,
regularActions = {
IconButton(
onClick = onBrightness
@ -119,6 +119,17 @@ private fun ReceiveTopAppBar(
contentDescription = stringResource(R.string.receive_brightness_content_description)
)
}
},
hamburgerMenuActions = {
IconButton(
onClick = onSettings,
modifier = Modifier.testTag(CommonTag.SETTINGS_TOP_BAR_BUTTON)
) {
Icon(
painter = painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.hamburger_menu_icon),
contentDescription = stringResource(id = R.string.settings_menu_content_description)
)
}
}
)
}

View File

@ -12,6 +12,7 @@ import cash.z.ecc.sdk.model.ZecRequest
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.request.view.Request
import kotlinx.coroutines.runBlocking
@ -29,7 +30,8 @@ private fun WrapRequest(
val walletAddresses = walletViewModel.addresses.collectAsStateWithLifecycle().value
if (null == walletAddresses) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
Request(
walletAddresses.unified,

View File

@ -10,6 +10,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.scan.util.SettingsUtil
import co.electriccoin.zcash.ui.screen.scan.view.Scan
import kotlinx.coroutines.launch
@ -40,7 +41,8 @@ fun WrapScan(
val scope = rememberCoroutineScope()
if (synchronizer == null) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
Scan(
snackbarHostState = snackbarHostState,

View File

@ -9,6 +9,7 @@ import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.seedrecovery.view.SeedRecovery
@Composable
@ -39,7 +40,8 @@ private fun WrapSeedRecovery(
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
if (null == synchronizer || null == persistableWallet) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
SeedRecovery(
persistableWallet,

View File

@ -18,9 +18,9 @@ import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.sdk.extension.send
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.send.ext.Saver
import co.electriccoin.zcash.ui.screen.send.model.SendArgumentsWrapper
import co.electriccoin.zcash.ui.screen.send.model.SendStage
@ -28,20 +28,12 @@ import co.electriccoin.zcash.ui.screen.send.view.Send
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapSend(
sendArgumentsWrapper: SendArgumentsWrapper?,
goToQrScanner: () -> Unit,
goBack: () -> Unit
) {
WrapSend(this, sendArgumentsWrapper, goToQrScanner, goBack)
}
@Composable
private fun WrapSend(
internal fun WrapSend(
activity: ComponentActivity,
sendArgumentsWrapper: SendArgumentsWrapper?,
goToQrScanner: () -> Unit,
goBack: () -> Unit
goBack: () -> Unit,
goSettings: () -> Unit,
) {
val hasCameraFeature = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
@ -53,7 +45,16 @@ private fun WrapSend(
val spendingKey = walletViewModel.spendingKey.collectAsStateWithLifecycle().value
WrapSend(sendArgumentsWrapper, synchronizer, spendableBalance, spendingKey, goToQrScanner, goBack, hasCameraFeature)
WrapSend(
sendArgumentsWrapper,
synchronizer,
spendableBalance,
spendingKey,
goToQrScanner,
goBack,
goSettings,
hasCameraFeature
)
}
@Suppress("LongParameterList")
@ -66,6 +67,7 @@ internal fun WrapSend(
spendingKey: UnifiedSpendingKey?,
goToQrScanner: () -> Unit,
goBack: () -> Unit,
goSettings: () -> Unit,
hasCameraFeature: Boolean
) {
val scope = rememberCoroutineScope()
@ -83,11 +85,13 @@ internal fun WrapSend(
when (sendStage) {
SendStage.Form -> goBack()
SendStage.Confirmation -> setSendStage(SendStage.Form)
SendStage.Sending -> { // no action - wait until done
}
SendStage.Sending -> { /* no action - wait until the sending is done */ }
SendStage.SendFailure -> setSendStage(SendStage.Form)
SendStage.SendSuccessful -> goBack()
SendStage.SendSuccessful -> {
setZecSend(null)
setSendStage(SendStage.Form)
goBack()
}
}
}
@ -96,7 +100,8 @@ internal fun WrapSend(
}
if (null == synchronizer || null == spendableBalance || null == spendingKey) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
Send(
mySpendableBalance = spendableBalance,
@ -106,6 +111,7 @@ internal fun WrapSend(
zecSend = zecSend,
onZecSendChange = setZecSend,
onBack = onBackAction,
onSettings = goSettings,
onCreateAndSend = {
scope.launch {
Twig.debug { "Sending transaction" }

View File

@ -0,0 +1,11 @@
package co.electriccoin.zcash.ui.screen.send
/**
* These are only used for automated testing.
*/
object SendTag {
const val SEND_FORM_BUTTON = "send_form_button"
const val SEND_CONFIRMATION_BUTTON = "send_confirmation_button"
const val SEND_FAILED_BUTTON = "send_failed_button"
const val SEND_SUCCESS_BUTTON = "send_success_button"
}

View File

@ -14,14 +14,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -30,6 +27,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@ -46,14 +45,17 @@ import cash.z.ecc.android.sdk.model.toZecString
import cash.z.ecc.sdk.fixture.MemoFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.test.CommonTag
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.FormTextField
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.ZcashTheme.dimens
import co.electriccoin.zcash.ui.screen.send.SendTag
import co.electriccoin.zcash.ui.screen.send.ext.ABBREVIATION_INDEX
import co.electriccoin.zcash.ui.screen.send.ext.abbreviated
import co.electriccoin.zcash.ui.screen.send.ext.valueOrEmptyChar
@ -76,6 +78,7 @@ private fun PreviewSendForm() {
onCreateAndSend = {},
onQrScannerOpen = {},
onBack = {},
onSettings = {},
hasCameraFeature = true
)
}
@ -123,7 +126,7 @@ private fun PreviewSendFailure() {
private fun PreviewSendConfirmation() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
Confirmation(
SendConfirmation(
zecSend =
ZecSend(
destination = runBlocking { WalletAddressFixture.sapling() },
@ -146,6 +149,7 @@ fun Send(
zecSend: ZecSend?,
onZecSendChange: (ZecSend) -> Unit,
onBack: () -> Unit,
onSettings: () -> Unit,
onCreateAndSend: (ZecSend) -> Unit,
onQrScannerOpen: () -> Unit,
hasCameraFeature: Boolean
@ -153,7 +157,8 @@ fun Send(
Scaffold(topBar = {
SendTopAppBar(
onBack = onBack,
showBackNavigationButton = sendStage != SendStage.Sending
onSettings = onSettings,
showBackNavigationButton = (sendStage != SendStage.Sending && sendStage != SendStage.Form)
)
}) { paddingValues ->
SendMainContent(
@ -184,28 +189,33 @@ fun Send(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SendTopAppBar(
onBack: () -> Unit,
onSettings: () -> Unit,
showBackNavigationButton: Boolean = true
) {
if (showBackNavigationButton) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.send_title)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.send_back_content_description)
)
}
SmallTopAppBar(
titleText = stringResource(id = R.string.send_title),
onBack = onBack,
backText =
if (showBackNavigationButton) {
stringResource(id = R.string.send_back)
} else {
null
},
backContentDescriptionText = stringResource(id = R.string.send_back_content_description),
hamburgerMenuActions = {
IconButton(
onClick = onSettings,
modifier = Modifier.testTag(CommonTag.SETTINGS_TOP_BAR_BUTTON)
) {
Icon(
painter = painterResource(id = co.electriccoin.zcash.ui.design.R.drawable.hamburger_menu_icon),
contentDescription = stringResource(id = R.string.settings_menu_content_description)
)
}
)
} else {
TopAppBar(title = { Text(text = stringResource(id = R.string.send_title)) })
}
}
)
}
@Suppress("LongParameterList")
@ -239,7 +249,7 @@ private fun SendMainContent(
)
}
(sendStage == SendStage.Confirmation) -> {
Confirmation(
SendConfirmation(
zecSend = zecSend,
onConfirmation = {
onSendStageChange(SendStage.Sending)
@ -426,13 +436,14 @@ private fun SendForm(
// Check for ABBREVIATION_INDEX goes away once proper address validation is in place.
// For now, it just prevents a crash on the confirmation screen.
enabled = amountZecString.isNotBlank() && recipientAddressString.length > ABBREVIATION_INDEX,
outerPaddingValues = PaddingValues(top = dimens.spacingNone)
outerPaddingValues = PaddingValues(top = dimens.spacingNone),
modifier = Modifier.testTag(SendTag.SEND_FORM_BUTTON)
)
}
}
@Composable
private fun Confirmation(
private fun SendConfirmation(
zecSend: ZecSend,
onConfirmation: () -> Unit,
modifier: Modifier = Modifier
@ -465,7 +476,10 @@ private fun Confirmation(
)
PrimaryButton(
modifier = Modifier.padding(top = dimens.spacingSmall),
modifier =
Modifier
.padding(top = dimens.spacingSmall)
.testTag(SendTag.SEND_CONFIRMATION_BUTTON),
onClick = onConfirmation,
text = stringResource(id = R.string.send_confirmation_button),
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
@ -566,7 +580,10 @@ private fun SendSuccessful(
)
PrimaryButton(
modifier = Modifier.padding(top = dimens.spacingSmall),
modifier =
Modifier
.padding(top = dimens.spacingSmall)
.testTag(SendTag.SEND_SUCCESS_BUTTON),
text = stringResource(R.string.send_successful_button),
onClick = onDone,
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)
@ -616,7 +633,10 @@ private fun SendFailure(
)
PrimaryButton(
modifier = Modifier.padding(top = dimens.spacingSmall),
modifier =
Modifier
.padding(top = dimens.spacingSmall)
.testTag(SendTag.SEND_FAILED_BUTTON),
text = stringResource(R.string.send_failure_button),
onClick = onDone,
outerPaddingValues = PaddingValues(top = dimens.spacingSmall)

View File

@ -3,6 +3,7 @@
package co.electriccoin.zcash.ui.screen.settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -11,6 +12,7 @@ import co.electriccoin.zcash.ui.common.model.VersionInfo
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.settings.model.TroubleshootingParameters
import co.electriccoin.zcash.ui.screen.settings.view.Settings
import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel
@ -60,13 +62,18 @@ private fun WrapSettings(
val isKeepScreenOnWhileSyncing = settingsViewModel.isKeepScreenOnWhileSyncing.collectAsStateWithLifecycle().value
val isAnalyticsEnabled = settingsViewModel.isAnalyticsEnabled.collectAsStateWithLifecycle().value
BackHandler {
goBack()
}
@Suppress("ComplexCondition")
if (null == synchronizer ||
null == isAnalyticsEnabled ||
null == isBackgroundSyncEnabled ||
null == isKeepScreenOnWhileSyncing
) {
// Display loading indicator
// Improve this by allowing screen composition and updating it after the data is available
CircularScreenProgressIndicator()
} else {
Settings(
TroubleshootingParameters(

View File

@ -1,7 +1,4 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="account_menu_content_description">Open Settings</string>
<string name="account_button_receive">Receive</string>
<string name="account_button_send">Send</string>
<string name="account_button_history">Transaction History</string>
<string name="account_status_syncing_format" formatted="true">Syncing - <xliff:g id="synced_percent" example="50.25">

View File

@ -0,0 +1,3 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="balances_title">Balances</string>
</resources>

View File

@ -3,4 +3,5 @@
<string name="empty_char">-</string>
<string name="zcash_logo_content_description">Zcash logo</string>
<string name="not_implemented_yet">Not implemented yet.</string>
<string name="settings_menu_content_description">Open Settings</string>
</resources>

View File

@ -1,24 +1,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="home_menu_content_description">Open Settings</string>
<string name="home_button_receive">Receive</string>
<string name="home_button_send">Send</string>
<string name="home_button_history">Transaction History</string>
<string name="home_status_syncing_format" formatted="true">Syncing - <xliff:g id="synced_percent" example="50.25">
%1$s</xliff:g>%%</string> <!-- double %% for escaping -->
<string name="home_status_syncing_catchup">Syncing</string>
<string name="home_status_syncing_amount_suffix" formatted="true"><xliff:g id="amount_prefix" example="123$">%1$s</xliff:g> so far</string>
<string name="home_status_syncing_additional_information">We will show you funds as we discover them.</string>
<string name="home_status_up_to_date">Up-to-date</string>
<string name="home_status_sending_format" formatted="true">Sending <xliff:g id="sending_amount" example=".023">%1$s</xliff:g></string>
<string name="home_status_receiving_format" formatted="true">Receiving <xliff:g id="receiving_amount" example=".023">%1$s</xliff:g> ZEC</string>
<string name="home_status_shielding_format" formatted="true">Shielding <xliff:g id="shielding_amount" example=".023">%1$s</xliff:g> ZEC</string>
<string name="home_status_update">Please Update</string>
<string name="home_status_error" formatted="true">Error: <xliff:g id="error_type" example="Lost connection">%1$s</xliff:g></string>
<string name="home_status_error_connection">Disconnected</string>
<string name="home_status_error_unknown">Unknown cause</string>
<string name="home_status_stopped">Synchronizer stopped</string>
<string name="home_status_updating_blockheight">Updating blockheight</string>
<string name="home_status_fiat_currency_price_out_of_date" formatted="true"><xliff:g id="fiat_currency" example="USD">%1$s</xliff:g> price out-of-date</string>
<string name="home_status_spendable" formatted="true">Fully spendable in <xliff:g id="spendable_time" example="2 minutes">%1$s</xliff:g></string>
<string name="home_tab_account">Account</string>
<string name="home_tab_send">Send</string>
<string name="home_tab_receive">Receive</string>
<string name="home_tab_balances">Balances</string>
</resources>

View File

@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="receive_title">Receive</string>
<string name="receive_back">Back</string>
<string name="receive_back_content_description">Back</string>
<string name="receive_brightness_content_description">Adjust brightness</string>
<string name="receive_qr_code_content_description">QR code for address</string>
<string name="receive_caption">Your Address</string>

View File

@ -1,5 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="send_title">Send ZEC</string>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="send_title">Send</string>
<string name="send_back">Back</string>
<string name="send_back_content_description">Back</string>
<string name="send_scan_content_description">Scan</string>
<string name="send_balance" formatted="true"><xliff:g id="balance" example="12.345">%1$s</xliff:g> ZEC Available</string>

View File

@ -7,9 +7,10 @@ import android.os.Build
import android.os.LocaleList
import androidx.activity.viewModels
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
@ -40,9 +41,11 @@ import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
import co.electriccoin.zcash.ui.design.component.UiMode
import co.electriccoin.zcash.ui.screen.account.AccountTag
import co.electriccoin.zcash.ui.screen.home.HomeTag
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.settings.SettingsTag
import co.electriccoin.zcash.ui.screen.send.SendTag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@ -95,6 +98,13 @@ class ScreenshotTest : UiTestPrerequisites() {
}
}
private fun ComposeContentTestRule.navigateInHomeTab(destinationTag: String) {
onNodeWithTag(destinationTag).also {
it.assertExists()
it.performClick()
}
}
private fun runWith(
uiMode: UiMode,
locale: String,
@ -265,17 +275,22 @@ class ScreenshotTest : UiTestPrerequisites() {
return
}
// These are the home screen bottom navigation sub-screens
onboardingScreenshots(resContext, tag, composeTestRule)
recoveryScreenshots(resContext, tag, composeTestRule)
homeScreenshots(resContext, tag, composeTestRule)
// These are the buttons on the home screen
navigateTo(NavigationTargets.SEND)
composeTestRule.navigateInHomeTab(HomeTag.TAB_ACCOUNT)
accountScreenshots(tag, composeTestRule)
composeTestRule.navigateInHomeTab(HomeTag.TAB_SEND)
sendZecScreenshots(resContext, tag, composeTestRule)
navigateTo(NavigationTargets.RECEIVE)
composeTestRule.navigateInHomeTab(HomeTag.TAB_RECEIVE)
receiveZecScreenshots(resContext, tag, composeTestRule)
composeTestRule.navigateInHomeTab(HomeTag.TAB_BALANCES)
balancesScreenshots(resContext, tag, composeTestRule)
navigateTo(NavigationTargets.WALLET_ADDRESS_DETAILS)
addressDetailsScreenshots(resContext, tag, composeTestRule)
@ -283,7 +298,7 @@ class ScreenshotTest : UiTestPrerequisites() {
transactionHistoryScreenshots(resContext, tag, composeTestRule)
navigateTo(NavigationTargets.SETTINGS)
settingsScreenshots(tag, composeTestRule)
settingsScreenshots(resContext, tag, composeTestRule)
// These are the Settings screen items
// We could manually click on each one, which is a better integration test but a worse screenshot test
@ -362,8 +377,7 @@ private fun recoveryScreenshots(
}
}
private fun homeScreenshots(
resContext: Context,
private fun accountScreenshots(
tag: String,
composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>
) {
@ -374,17 +388,32 @@ private fun homeScreenshots(
composeTestRule.activity.walletViewModel.walletSnapshot.value != null
}
composeTestRule.onNode(hasText(resContext.getString(R.string.home_button_send), ignoreCase = true)).also {
composeTestRule.onNodeWithTag(AccountTag.STATUS_VIEWS).also {
it.assertExists()
ScreenshotTest.takeScreenshot(tag, "Home 1")
ScreenshotTest.takeScreenshot(tag, "Account 1")
}
}
private fun balancesScreenshots(
resContext: Context,
tag: String,
composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>
) {
// TODO [#1127]: Implement Balances screen
// TODO [#1127]: https://github.com/Electric-Coin-Company/zashi-android/issues/1127
composeTestRule.onNodeWithText(resContext.getString(R.string.not_implemented_yet)).also {
it.assertExists()
ScreenshotTest.takeScreenshot(tag, "Balances 1")
}
}
private fun settingsScreenshots(
resContext: Context,
tag: String,
composeTestRule: ComposeTestRule
) {
composeTestRule.onNode(hasTestTag(SettingsTag.SETTINGS_TOP_APP_BAR)).also {
composeTestRule.onNode(hasText(resContext.getString(R.string.settings_backup_wallet), ignoreCase = true)).also {
it.assertExists()
}
@ -438,19 +467,16 @@ private fun receiveZecScreenshots(
composeTestRule.activity.walletViewModel.addresses.value != null
}
composeTestRule.onNode(hasText(resContext.getString(R.string.receive_title), ignoreCase = true)).also {
composeTestRule.onNode(
hasContentDescription(
value = resContext.getString(R.string.receive_qr_code_content_description),
ignoreCase = true
)
).also {
it.assertExists()
}
ScreenshotTest.takeScreenshot(tag, "Receive 1")
composeTestRule.onNodeWithText(resContext.getString(R.string.receive_see_address_details), ignoreCase = true).also {
it.performClick()
}
composeTestRule.waitForIdle()
ScreenshotTest.takeScreenshot(tag, "Address details")
}
private fun sendZecScreenshots(
@ -495,7 +521,7 @@ private fun sendZecScreenshots(
// Screenshot: Fulfilled form
ScreenshotTest.takeScreenshot(tag, "Send 2")
composeTestRule.onNodeWithText(resContext.getString(R.string.send_create), ignoreCase = true).also {
composeTestRule.onNodeWithTag(SendTag.SEND_FORM_BUTTON).also {
it.performClick()
}