[#310] [Scaffold] Progress Status Circular Bar

* [#310] [Scaffold] Progress Status Circular Bar

- Added Z to Home ZECs balance
- Added USD balance text to Home
- Prepared Text extension functions for both
- Provided Zatoshi to USD conversion methods + filed related SDK conversion issue

* Update Home screen UI with progress bars

- Implemented scaffolding UI of progress bars
- Added related texts to strings

* Update Home screen UI with progress bars

- Connected some of the implemented UI elements to SDK values
- Added app update information to Home screen
- Update WalletSnapshot with progress field

* Update Home screen UI with progress bars

- Capturing and handling errors from SDK Synchronizer.
- Added related error strings.
-  Simplified Home screen UI.

* Zboto font added. Load it in runtime. Import to Typography.

* Updated ZEC sign icon.

* Draw ZEC balance with Zboto font

* Simplify Home screen balances assigning

* Switch to PercentDecimal progress representatiton

* Support different locales while working with fiat currency

* Fix bug in checking of fiat currency value

* Generalize strings to provide possibility of other fiat currencies

* Add fiat currency conversion states mechanism

* Add TODO comment with reference to follow up SynchronizerError issue

* Add WalletDisplayValues to simplify HomeView composable

* Add CurrencyConversion class for connection to Price API (and convert Zatoshi to fiat currency)

* Add basic HomeView tests

* Add basic HomeViewIntegration test

* Review changes

- Used Duration API for times
 - Allow injecting clock into currency conversion
 - Moved FiatCurrencyConversionRateState to sdk-ext-ui because I suspect that we’ll consider this to be a UI object.  I based this on the fact that current/stale cutoff values are arbitrary and probably should be the domain of the UI rather than the SDK.
 - Added some tests, although additional coverage is needed
 - Added fixtures for model objects

* Minor code refactoring

- Move UpdateInfoFixture class to fixture dir
- Remove unnecessary annotation
- Add common application context method to test suite
- Fix Test class import
- Move several WalletSnapshotFixture parameters to const fields

* Add WalletDisplayValuesTest to cover the model class.

* Fix import after changes merged

* Use the new MonetarySeparatorsFixture in related tests

* Add a few basic Zatoshi -> USD conversion tests

* Turn on core lib desugaring for sdk-ext-ui-lib module

* Make WalletDisplayValues a data class

I think there may be some instances where this can help with recomposition

* Add preference key for fiat currency

This allows us to configure reading the value with observers correctly, even if we don’t allow the user to change it right now.

* Delegate symbol and formatting to JVM

* Add tests for Locale

Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
Honza Rychnovsky 2022-07-13 09:16:05 +02:00 committed by GitHub
parent f31cf5b486
commit 32c20953f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1424 additions and 92 deletions

View File

@ -13,6 +13,7 @@ dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
api(libs.zcash.sdk)
api(libs.zcash.bip39)

View File

@ -0,0 +1,18 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.CurrencyConversion
import cash.z.ecc.sdk.model.FiatCurrency
import kotlinx.datetime.Instant
import kotlinx.datetime.toInstant
object CurrencyConversionFixture {
val FIAT_CURRENCY = FiatCurrencyFixture.new()
val TIMESTAMP = "2022-07-08T11:51:44Z".toInstant()
const val PRICE_OF_ZEC = 54.98
fun new(
fiatCurrency: FiatCurrency = FIAT_CURRENCY,
timestamp: Instant = TIMESTAMP,
priceOfZec: Double = PRICE_OF_ZEC
) = CurrencyConversion(fiatCurrency, timestamp, priceOfZec)
}

View File

@ -0,0 +1,9 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.sdk.model.FiatCurrency
object FiatCurrencyFixture {
const val USD = "USD"
fun new(code: String = USD) = FiatCurrency(code)
}

View File

@ -0,0 +1,35 @@
package cash.z.ecc.sdk.model
import kotlinx.datetime.Instant
/**
* Represents a snapshot in time of a currency conversion rate.
*
* @param fiatCurrency The fiat currency for this conversion.
* @param timestamp The timestamp when this conversion was obtained. This value is returned by
* the server so it shouldn't have issues with client-side clock inaccuracy.
* @param priceOfZec The conversion rate of ZEC to the fiat currency.
*/
data class CurrencyConversion(
val fiatCurrency: FiatCurrency,
val timestamp: Instant,
val priceOfZec: Double
) {
init {
require(priceOfZec > 0) { "priceOfZec must be greater than 0" }
require(priceOfZec.isFinite()) { "priceOfZec must be finite" }
}
}
/**
* Represents an ISO 4217 currency code.
*/
@Suppress("MagicNumber")
data class FiatCurrency(val code: String) {
init {
require(code.length == 3) { "Fiat currency code must be 3 characters long." }
// TODO [#532] https://github.com/zcash/secant-android-wallet/issues/532
// Add another check to make sure the code is in the known ISO currency code list.
}
}

View File

@ -8,12 +8,18 @@ plugins {
android {
resourcePrefix = "co_electriccoin_zcash_"
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(projects.sdkExtLib)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.kotlin.test)

View File

@ -0,0 +1,109 @@
package cash.z.ecc.sdk.ext.ui.model
import androidx.test.filters.SmallTest
import cash.z.ecc.sdk.ext.ui.fixture.LocaleFixture
import cash.z.ecc.sdk.ext.ui.fixture.MonetarySeparatorsFixture
import cash.z.ecc.sdk.ext.ui.toFiatCurrencyState
import cash.z.ecc.sdk.fixture.CurrencyConversionFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertIs
import kotlin.time.Duration.Companion.seconds
class FiatCurrencyConversionRateStateTest {
@Test
@SmallTest
fun future_near() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(
CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.FUTURE_CUTOFF_AGE_INCLUSIVE
)
val currencyConversion = CurrencyConversionFixture.new()
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Current>(result)
}
@Test
@SmallTest
fun future_far() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(
CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.FUTURE_CUTOFF_AGE_INCLUSIVE - 1.seconds
)
val currencyConversion = CurrencyConversionFixture.new()
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Unavailable>(result)
}
@Test
@SmallTest
fun current() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(CurrencyConversionFixture.TIMESTAMP)
val currencyConversion = CurrencyConversionFixture.new(
timestamp = CurrencyConversionFixture.TIMESTAMP - 1.seconds
)
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Current>(result)
}
@Test
@SmallTest
fun stale() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(CurrencyConversionFixture.TIMESTAMP)
val currencyConversion = CurrencyConversionFixture.new(
timestamp = CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.CURRENT_CUTOFF_AGE_INCLUSIVE - 1.seconds
)
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Stale>(result)
}
@Test
@SmallTest
fun too_stale() {
val zatoshi = ZatoshiFixture.new()
val frozenClock = FrozenClock(CurrencyConversionFixture.TIMESTAMP)
val currencyConversion = CurrencyConversionFixture.new(
timestamp = CurrencyConversionFixture.TIMESTAMP - FiatCurrencyConversionRateState.STALE_CUTOFF_AGE_INCLUSIVE - 1.seconds
)
val result = zatoshi.toFiatCurrencyState(currencyConversion, LocaleFixture.new(), MonetarySeparatorsFixture.new(), frozenClock)
assertIs<FiatCurrencyConversionRateState.Unavailable>(result)
}
@Test
@SmallTest
fun null_conversion_rate() {
val zatoshi = ZatoshiFixture.new()
val result = zatoshi.toFiatCurrencyState(null, LocaleFixture.new(), MonetarySeparatorsFixture.new())
assertIs<FiatCurrencyConversionRateState.Unavailable>(result)
}
}
private class FrozenClock(private val timestamp: Instant) : Clock {
override fun now() = timestamp
}

View File

@ -0,0 +1,26 @@
package cash.z.ecc.sdk.ext.ui.model
import androidx.test.filters.SmallTest
import org.junit.Test
import kotlin.test.assertEquals
class LocaleTest {
@Test
@SmallTest
fun toKotlinLocale() {
val javaLocale = java.util.Locale.forLanguageTag("en-US")
val kotlinLocale = javaLocale.toKotlinLocale()
assertEquals("en", kotlinLocale.language)
assertEquals("US", kotlinLocale.region)
assertEquals(null, kotlinLocale.variant)
}
@Test
@SmallTest
fun toJavaLocale() {
val kotlinLocale = cash.z.ecc.sdk.ext.ui.model.Locale("en", "US", null)
val javaLocale = kotlinLocale.toJavaLocale()
assertEquals("en-US", javaLocale.toLanguageTag())
}
}

View File

@ -0,0 +1,77 @@
package cash.z.ecc.sdk.ext.ui.model
import androidx.test.filters.SmallTest
import cash.z.ecc.sdk.ext.ui.fixture.LocaleFixture
import cash.z.ecc.sdk.ext.ui.fixture.MonetarySeparatorsFixture
import cash.z.ecc.sdk.ext.ui.toFiatString
import cash.z.ecc.sdk.fixture.CurrencyConversionFixture
import cash.z.ecc.sdk.fixture.ZatoshiFixture
import org.junit.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ZatoshiExtTest {
companion object {
private val EN_US_SEPARATORS = MonetarySeparatorsFixture.new()
private val CURRENCY_CONVERSION = CurrencyConversionFixture.new()
}
@Test
@SmallTest
fun zero_zatoshi_to_fiat_conversion_test() {
val zatoshi = ZatoshiFixture.new(0L)
val fiatString = zatoshi.toFiatString(CURRENCY_CONVERSION, LocaleFixture.new(), EN_US_SEPARATORS)
fiatString.also {
assertNotNull(it)
assertTrue(it.isNotEmpty())
assertTrue(it.contains("0"))
assertTrue(it.isValidNumber(EN_US_SEPARATORS))
}
}
@Test
@SmallTest
fun regular_zatoshi_to_fiat_conversion_test() {
val zatoshi = ZatoshiFixture.new(123_456_789L)
val fiatString = zatoshi.toFiatString(CURRENCY_CONVERSION, LocaleFixture.new(), EN_US_SEPARATORS)
fiatString.also {
assertNotNull(it)
assertTrue(it.isNotEmpty())
assertTrue(it.isValidNumber(EN_US_SEPARATORS))
}
}
@Test
@SmallTest
fun rounded_zatoshi_to_fiat_conversion_test() {
val roundedZatoshi = ZatoshiFixture.new(100_000_000L)
val roundedCurrencyConversion = CurrencyConversionFixture.new(
priceOfZec = 100.0
)
val fiatString = roundedZatoshi.toFiatString(
roundedCurrencyConversion,
LocaleFixture.new(),
EN_US_SEPARATORS
)
fiatString.also {
assertNotNull(it)
assertTrue(it.isNotEmpty())
assertTrue(it.isValidNumber(EN_US_SEPARATORS))
assertTrue("$100${EN_US_SEPARATORS.decimal}00" == it)
}
}
}
private fun Char.isDigitOrSeparator(separators: MonetarySeparators): Boolean {
return this.isDigit() || this == separators.decimal || this == separators.grouping
}
private fun String.isValidNumber(separators: MonetarySeparators): Boolean {
return this
.drop(1) // remove currency symbol
.all { return it.isDigitOrSeparator(separators) }
}

View File

@ -5,6 +5,7 @@ import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.ext.ui.fixture.MonetarySeparatorsFixture
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Test
@ -15,7 +16,7 @@ import kotlin.test.assertNull
class ZecStringTest {
companion object {
private val EN_US_MONETARY_SEPARATORS = MonetarySeparators(',', '.')
private val EN_US_MONETARY_SEPARATORS = MonetarySeparatorsFixture.new()
private val context = run {
val applicationContext = ApplicationProvider.getApplicationContext<Context>()
val enUsConfiguration = Configuration(applicationContext.resources.configuration).apply {

View File

@ -3,7 +3,7 @@ package cash.z.ecc.sdk.ext.ui.regex
import androidx.test.filters.SmallTest
import cash.z.ecc.sdk.ext.ui.R
import cash.z.ecc.sdk.ext.ui.ZecStringExt
import cash.z.ecc.sdk.ext.ui.model.MonetarySeparators
import cash.z.ecc.sdk.ext.ui.fixture.MonetarySeparatorsFixture
import cash.z.ecc.sdk.ext.ui.test.getStringResourceWithArgs
import org.junit.Test
import kotlin.test.assertFalse
@ -13,7 +13,7 @@ import kotlin.test.assertTrue
class ZecStringExtTest {
companion object {
private val EN_US_SEPARATORS = MonetarySeparators(',', '.')
private val EN_US_SEPARATORS = MonetarySeparatorsFixture.new()
}
private fun getContinuousRegex(): Regex {

View File

@ -0,0 +1,90 @@
@file:Suppress("ktlint:filename")
package cash.z.ecc.sdk.ext.ui
import cash.z.ecc.android.sdk.ext.Conversions
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.ext.ui.model.FiatCurrencyConversionRateState
import cash.z.ecc.sdk.ext.ui.model.Locale
import cash.z.ecc.sdk.ext.ui.model.MonetarySeparators
import cash.z.ecc.sdk.ext.ui.model.toJavaLocale
import cash.z.ecc.sdk.model.CurrencyConversion
import kotlinx.datetime.Clock
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Currency
import kotlin.time.Duration
fun Zatoshi.toFiatCurrencyState(
currencyConversion: CurrencyConversion?,
locale: Locale,
monetarySeparators: MonetarySeparators,
clock: Clock = Clock.System
): FiatCurrencyConversionRateState {
if (currencyConversion == null) {
return FiatCurrencyConversionRateState.Unavailable
}
val fiatCurrencyConversionRate = toFiatString(currencyConversion, locale, monetarySeparators)
val currentSystemTime = clock.now()
val age = currentSystemTime - currencyConversion.timestamp
return if (age < Duration.ZERO && age.absoluteValue > FiatCurrencyConversionRateState.FUTURE_CUTOFF_AGE_INCLUSIVE) {
// Special case if the device's clock is set to the future.
// TODO [#535]: Consider using NTP requests to get the correct time instead of relying on the device's clock.
FiatCurrencyConversionRateState.Unavailable
} else if (age <= FiatCurrencyConversionRateState.CURRENT_CUTOFF_AGE_INCLUSIVE) {
FiatCurrencyConversionRateState.Current(fiatCurrencyConversionRate)
} else if (age <= FiatCurrencyConversionRateState.STALE_CUTOFF_AGE_INCLUSIVE) {
FiatCurrencyConversionRateState.Stale(fiatCurrencyConversionRate)
} else {
FiatCurrencyConversionRateState.Unavailable
}
}
fun Zatoshi.toFiatString(
currencyConversion: CurrencyConversion,
locale: Locale,
monetarySeparators: MonetarySeparators
) =
convertZatoshiToZecDecimal()
.convertZecDecimalToFiatDecimal(BigDecimal(currencyConversion.priceOfZec))
.convertFiatDecimalToFiatString(
Currency.getInstance(currencyConversion.fiatCurrency.code),
locale.toJavaLocale(),
monetarySeparators
)
private fun Zatoshi.convertZatoshiToZecDecimal(): BigDecimal {
return BigDecimal(value, MathContext.DECIMAL128).divide(
Conversions.ONE_ZEC_IN_ZATOSHI,
MathContext.DECIMAL128
).setScale(Conversions.ZEC_FORMATTER.maximumFractionDigits, RoundingMode.HALF_EVEN)
}
private fun BigDecimal.convertZecDecimalToFiatDecimal(zecPrice: BigDecimal): BigDecimal {
return multiply(zecPrice, MathContext.DECIMAL128)
}
private fun BigDecimal.convertFiatDecimalToFiatString(
fiatCurrency: Currency,
locale: java.util.Locale,
monetarySeparators: MonetarySeparators
): String {
return NumberFormat.getCurrencyInstance(locale).apply {
currency = fiatCurrency
roundingMode = RoundingMode.HALF_EVEN
if (this is DecimalFormat) {
decimalFormatSymbols.apply {
decimalSeparator = monetarySeparators.decimal
monetaryDecimalSeparator = monetarySeparators.decimal
groupingSeparator = monetarySeparators.grouping
}
}
}.format(this)
}

View File

@ -0,0 +1,15 @@
package cash.z.ecc.sdk.ext.ui.fixture
import cash.z.ecc.sdk.ext.ui.model.Locale
object LocaleFixture {
const val LANGUAGE = "en"
const val COUNTRY = "US"
val VARIANT: String? = null
fun new(
language: String = LANGUAGE,
country: String? = COUNTRY,
variant: String? = VARIANT
) = Locale(language, country, variant)
}

View File

@ -0,0 +1,13 @@
package cash.z.ecc.sdk.ext.ui.fixture
import cash.z.ecc.sdk.ext.ui.model.MonetarySeparators
object MonetarySeparatorsFixture {
const val US_GROUPING_SEPARATOR = ','
const val US_DECIMAL_SEPARATOR = '.'
fun new(
grouping: Char = US_GROUPING_SEPARATOR,
decimal: Char = US_DECIMAL_SEPARATOR
) = MonetarySeparators(grouping, decimal)
}

View File

@ -0,0 +1,40 @@
package cash.z.ecc.sdk.ext.ui.model
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
/**
* Represents a state of current fiat currency conversion to ZECs.
*/
sealed class FiatCurrencyConversionRateState {
/**
* @param formattedFiatValue A fiat value formatted as a localized string. E.g. $1.00.
*/
data class Current(val formattedFiatValue: String) : FiatCurrencyConversionRateState()
/**
* @param formattedFiatValue A fiat value formatted as a localized string. E.g. $1.00.
*/
data class Stale(val formattedFiatValue: String) : FiatCurrencyConversionRateState()
object Unavailable : FiatCurrencyConversionRateState()
companion object {
/**
* Cutoff negative age. Some users may intentionally set their clock forward 10 minutes
* because they're always late to things. This allows the app to mostly work for those users,
* while still failing if the clock is way off.
*/
val FUTURE_CUTOFF_AGE_INCLUSIVE = 10.minutes
/**
* Cutoff age for next attempt to refresh the conversion rate from the API.
*/
val CURRENT_CUTOFF_AGE_INCLUSIVE = 1.minutes
/**
* Cutoff age for displaying conversion rate from prior app launches or background refresh.
*/
val STALE_CUTOFF_AGE_INCLUSIVE = 1.days
}
}

View File

@ -0,0 +1,31 @@
package cash.z.ecc.sdk.ext.ui.model
data class Locale(val language: String, val region: String?, val variant: String?) {
companion object
}
fun Locale.toJavaLocale(): java.util.Locale {
return if (!region.isNullOrEmpty() && !variant.isNullOrEmpty()) {
java.util.Locale(language, region, variant)
} else if (!region.isNullOrEmpty() && variant.isNullOrEmpty()) {
java.util.Locale(language, region)
} else {
java.util.Locale(language)
}
}
fun java.util.Locale.toKotlinLocale(): Locale {
val resultCountry = if (country.isNullOrEmpty()) {
null
} else {
country
}
val resultVariant = if (variant.isNullOrEmpty()) {
null
} else {
variant
}
return Locale(language, resultCountry, resultVariant)
}

View File

@ -0,0 +1,31 @@
package co.electriccoin.zcash.ui.design.compat
import android.content.Context
import androidx.annotation.FontRes
import androidx.core.content.res.ResourcesCompat
import co.electriccoin.zcash.spackle.AndroidApiVersion
import co.electriccoin.zcash.ui.design.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object FontCompat {
fun isFontPrefetchNeeded() = !AndroidApiVersion.isAtLeastO
suspend fun prefetchFontsLegacy(context: Context) {
prefetchFontLegacy(context, R.font.zboto)
}
/**
* Pre-fetches fonts on Android N (API 25) and below.
*/
/*
* ResourcesCompat is used implicitly by Compose on older Android versions.
* The backwards compatibility library performs disk IO and then
* caches the results. This moves that IO off the main thread, to prevent ANRs and
* jank during app startup.
*/
private suspend fun prefetchFontLegacy(context: Context, @FontRes fontRes: Int) =
withContext(Dispatchers.IO) {
ResourcesCompat.getFont(context, fontRes)
}
}

View File

@ -5,8 +5,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Composable
@ -35,6 +38,21 @@ fun Body(
)
}
@Composable
fun Small(
text: String,
modifier: Modifier = Modifier,
textAlign: TextAlign
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = modifier,
textAlign = textAlign
)
}
@Composable
fun ListItem(
text: String,
@ -77,3 +95,36 @@ fun Reference(
}
)
}
/**
* Pass amount of ZECs you want to display and the component appends ZEC symbol to it. We're using
* a custom font here, which is Roboto modified to replace the dollar symbol with the ZEC symbol internally.
*
* @param amount of ZECs to be displayed
* @param modifier to modify the Text UI element as needed
*/
@Composable
fun HeaderWithZecIcon(
amount: String,
modifier: Modifier = Modifier
) {
Text(
text = stringResource(R.string.amount_with_zec_currency_symbol, amount),
style = ZcashTheme.typography.zecBalance,
color = MaterialTheme.colorScheme.onBackground,
modifier = modifier
)
}
@Composable
fun BodyWithFiatCurrencySymbol(
amount: String,
modifier: Modifier = Modifier
) {
Text(
text = amount,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = modifier
)
}

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font
@ -33,6 +34,10 @@ private val RubikFontFamily = FontFamily(
Font(googleFont = RubikFont, fontProvider = provider, weight = FontWeight.Bold) // W700
)
private val Zboto = FontFamily(
Font(R.font.zboto, FontWeight.Normal)
)
// If you change this definition of our Typography, don't forget to check if you use only
// the defined font weights above, otherwise the closest one will be used.
internal val Typography = Typography(
@ -61,7 +66,8 @@ internal val Typography = Typography(
@Immutable
data class ExtendedTypography(
val chipIndex: TextStyle,
val listItem: TextStyle
val listItem: TextStyle,
val zecBalance: TextStyle
)
val LocalExtendedTypography = staticCompositionLocalOf {
@ -73,6 +79,11 @@ val LocalExtendedTypography = staticCompositionLocalOf {
),
listItem = Typography.bodyLarge.copy(
fontSize = 24.sp
),
zecBalance = TextStyle(
fontFamily = Zboto,
fontWeight = FontWeight.Normal,
fontSize = 30.sp
)
)
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/black"
android:pathData="M22.55,42V37.8Q19.7,37.3 17.875,35.625Q16.05,33.95 15.25,31.4L18.05,30.25Q18.9,32.65 20.5,33.825Q22.1,35 24.35,35Q26.75,35 28.3,33.8Q29.85,32.6 29.85,30.5Q29.85,28.3 28.475,27.1Q27.1,25.9 23.3,24.65Q19.7,23.5 17.925,21.6Q16.15,19.7 16.15,16.85Q16.15,14.1 17.925,12.25Q19.7,10.4 22.55,10.15V6H25.55V10.15Q27.8,10.4 29.425,11.625Q31.05,12.85 31.9,14.75L29.1,15.95Q28.4,14.35 27.225,13.625Q26.05,12.9 24.15,12.9Q21.85,12.9 20.5,13.95Q19.15,15 19.15,16.8Q19.15,18.7 20.65,19.875Q22.15,21.05 26.2,22.3Q29.6,23.35 31.225,25.325Q32.85,27.3 32.85,30.3Q32.85,33.45 31,35.375Q29.15,37.3 25.55,37.85V42Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="3.17dp"
android:height="5.57dp"
android:viewportWidth="3.17"
android:viewportHeight="5.57">
<path
android:pathData="M1.96,5.57l0,-0.75l1.21,0l0,-0.84l-1.96,0l1.62,-2.18l0.34,-0.44l0,0l0,-0.61l-1.21,0l0,-0.75l-0.75,0l0,0.75l-1.21,0l0,0.84l1.96,0l-1.62,2.12l-0.34,0.44l0,0.67l1.21,0l0,0.75l0.75,0z"
android:fillColor="#1d181a"/>
</vector>

Binary file not shown.

View File

@ -0,0 +1,4 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="amount_with_zec_currency_symbol" formatted="true">$<xliff:g id="zec_amount" example="123">%1$s</xliff:g></string>
<string name="amount_with_fiat_currency_symbol" formatted="true"><xliff:g id="fiat_currency_symbol" example="$">%1$s</xliff:g><xliff:g id="amount" example="123">%2$s</xliff:g></string>
</resources>

View File

@ -5,8 +5,8 @@ import android.content.Context
import androidx.activity.ComponentActivity
import co.electriccoin.zcash.spackle.getPackageInfoCompat
import co.electriccoin.zcash.spackle.versionCodeCompat
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import com.google.android.play.core.appupdate.AppUpdateInfo

View File

@ -4,9 +4,9 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.integration.test.common.IntegrationTestingActivity
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.update.viewmodel.UpdateViewModel

View File

@ -13,13 +13,11 @@ import co.electriccoin.zcash.ui.fixture.VersionInfoFixture
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
import co.electriccoin.zcash.ui.screen.about.view.About
import co.electriccoin.zcash.ui.test.getStringResource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class AboutViewTest {
@get:Rule
val composeTestRule = createComposeRule()

View File

@ -0,0 +1,75 @@
package co.electriccoin.zcash.ui.screen.home
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
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 onScanCount = AtomicInteger(0)
private val onProfileCount = AtomicInteger(0)
private val onSendCount = AtomicInteger(0)
private val onRequestCount = AtomicInteger(0)
fun getOnScanCount(): Int {
composeTestRule.waitForIdle()
return onScanCount.get()
}
fun getOnProfileCount(): Int {
composeTestRule.waitForIdle()
return onProfileCount.get()
}
fun getOnSendCount(): Int {
composeTestRule.waitForIdle()
return onSendCount.get()
}
fun getOnRequestCount(): Int {
composeTestRule.waitForIdle()
return onRequestCount.get()
}
fun getWalletSnapshot(): WalletSnapshot {
composeTestRule.waitForIdle()
return walletSnapshot
}
@Composable
fun getDefaultContent() {
Home(
walletSnapshot,
emptyList(),
goScan = {
onScanCount.incrementAndGet()
},
goProfile = {
onProfileCount.incrementAndGet()
},
goSend = {
onSendCount.incrementAndGet()
},
goRequest = {
onRequestCount.incrementAndGet()
},
resetSdk = {},
wipeEntireWallet = {},
isDebugMenuEnabled = false,
updateAvailable = false
)
}
fun setDefaultContent() {
composeTestRule.setContent {
ZcashTheme {
getDefaultContent()
}
}
}
}

View File

@ -0,0 +1,76 @@
package co.electriccoin.zcash.ui.screen.home.integration
import androidx.compose.ui.test.assertHeightIsAtLeast
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.sdk.model.PercentDecimal
import co.electriccoin.zcash.test.UiTestPrerequisites
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.screen.home.model.WalletSnapshot
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()
private fun newTestSetup(walletSnapshot: WalletSnapshot) = HomeTestSetup(
composeTestRule,
walletSnapshot
)
// This is just basic sanity check that we still have UI set up as expected after the state restore
@Test
@MediumTest
fun wallet_snapshot_restoration() {
val restorationTester = StateRestorationTester(composeTestRule)
val walletSnapshot = WalletSnapshotFixture.new(
status = Synchronizer.Status.DOWNLOADING,
progress = PercentDecimal(0.5f)
)
val testSetup = newTestSetup(walletSnapshot)
restorationTester.setContent {
testSetup.getDefaultContent()
}
assertNotEquals(WalletSnapshotFixture.STATUS, testSetup.getWalletSnapshot().status)
assertEquals(Synchronizer.Status.DOWNLOADING, 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.DOWNLOADING, testSetup.getWalletSnapshot().status)
assertNotEquals(WalletSnapshotFixture.PROGRESS, testSetup.getWalletSnapshot().progress)
assertEquals(0.5f, testSetup.getWalletSnapshot().progress.decimal)
composeTestRule.onNodeWithTag(HomeTag.PROGRESS).also {
it.assertIsDisplayed()
it.assertHeightIsAtLeast(1.dp)
}
composeTestRule.onNodeWithTag(HomeTag.PROGRESS).also {
it.assertIsDisplayed()
it.assertHeightIsAtLeast(1.dp)
}
composeTestRule.onNodeWithTag(HomeTag.SINGLE_LINE_TEXT).also {
it.assertIsDisplayed()
it.assertWidthIsAtLeast(1.dp)
}
}
}

View File

@ -0,0 +1,42 @@
package co.electriccoin.zcash.ui.screen.home.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.sdk.ext.ui.model.FiatCurrencyConversionRateState
import cash.z.ecc.sdk.ext.ui.model.toZecString
import cash.z.ecc.sdk.model.PercentDecimal
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.fixture.WalletSnapshotFixture
import co.electriccoin.zcash.ui.test.getAppContext
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.test.assertNotNull
class WalletDisplayValuesTest {
@Test
@SmallTest
fun download_running_test() {
val walletSnapshot = WalletSnapshotFixture.new(
progress = PercentDecimal.ONE_HUNDRED_PERCENT,
status = Synchronizer.Status.SCANNING,
orchardBalance = WalletSnapshotFixture.ORCHARD_BALANCE,
saplingBalance = WalletSnapshotFixture.SAPLING_BALANCE,
transparentBalance = WalletSnapshotFixture.TRANSPARENT_BALANCE
)
val values = WalletDisplayValues.getNextValues(
getAppContext(),
walletSnapshot,
false
)
assertNotNull(values)
assertEquals(1f, values.progress.decimal)
assertEquals(walletSnapshot.totalBalance().toZecString(), values.zecAmountText)
assertEquals(getStringResource(R.string.home_status_syncing_catchup), values.statusText)
// TODO [#578] https://github.com/zcash/zcash-android-wallet-sdk/issues/578
assertEquals(FiatCurrencyConversionRateState.Unavailable, values.fiatCurrencyAmountState)
assertEquals(getStringResource(R.string.fiat_currency_conversion_rate_unavailable), values.fiatCurrencyAmountText)
}
}

View File

@ -0,0 +1,132 @@
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.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_scan_content_description)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.home_profile_content_description)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(HomeTag.STATUS_VIEWS).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithText(getStringResource(R.string.home_button_send)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithText(getStringResource(R.string.home_button_request)).also {
it.assertIsDisplayed()
}
}
@Test
@MediumTest
fun click_scan_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnScanCount())
composeTestRule.clickScan()
assertEquals(1, testSetup.getOnScanCount())
}
@Test
@MediumTest
fun click_profile_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnProfileCount())
composeTestRule.clickProfile()
assertEquals(1, testSetup.getOnProfileCount())
}
@Test
@MediumTest
fun click_send_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnSendCount())
composeTestRule.clickSend()
assertEquals(1, testSetup.getOnSendCount())
}
@Test
@MediumTest
fun click_request_button() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnRequestCount())
composeTestRule.clickRequest()
assertEquals(1, testSetup.getOnRequestCount())
}
private fun newTestSetup() = HomeTestSetup(
composeTestRule,
WalletSnapshotFixture.new()
).apply {
setDefaultContent()
}
}
fun ComposeContentTestRule.clickScan() {
onNodeWithContentDescription(getStringResource(R.string.home_scan_content_description)).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickProfile() {
onNodeWithContentDescription(getStringResource(R.string.home_profile_content_description)).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickSend() {
onNodeWithText(getStringResource(R.string.home_button_send)).also {
it.performScrollTo()
it.performClick()
}
}
private fun ComposeContentTestRule.clickRequest() {
onNodeWithText(getStringResource(R.string.home_button_request)).also {
it.performScrollTo()
it.performClick()
}
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.screen.onboarding.view
package co.electriccoin.zcash.ui.screen.onboarding
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
@ -6,6 +6,7 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.onboarding.model.OnboardingStage
import co.electriccoin.zcash.ui.screen.onboarding.state.OnboardingState
import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding
import java.util.concurrent.atomic.AtomicInteger
class OnboardingTestSetup(

View File

@ -7,14 +7,13 @@ import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.UiTestingActivity
import co.electriccoin.zcash.ui.screen.onboarding.OnboardingTestSetup
import co.electriccoin.zcash.ui.screen.onboarding.model.OnboardingStage
import co.electriccoin.zcash.ui.screen.onboarding.view.OnboardingTestSetup
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
// TODO [#382]: https://github.com/zcash/secant-android-wallet/issues/382
class OnboardingActivityTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createAndroidComposeRule<UiTestingActivity>()

View File

@ -7,14 +7,13 @@ import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.onboarding.OnboardingTestSetup
import co.electriccoin.zcash.ui.screen.onboarding.model.OnboardingStage
import co.electriccoin.zcash.ui.screen.onboarding.view.OnboardingTestSetup
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
// TODO [#382]: https://github.com/zcash/secant-android-wallet/issues/382
class OnboardingIntegrationTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createComposeRule()

View File

@ -8,6 +8,7 @@ import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.onboarding.OnboardingTestSetup
import co.electriccoin.zcash.ui.screen.onboarding.model.OnboardingStage
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals

View File

@ -14,7 +14,6 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.MediumTest
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.sdk.fixture.SeedPhraseFixture
@ -25,6 +24,7 @@ import co.electriccoin.zcash.ui.design.component.CommonTag
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.restore.RestoreTag
import co.electriccoin.zcash.ui.screen.restore.state.WordList
import co.electriccoin.zcash.ui.test.getAppContext
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@ -48,7 +48,7 @@ class RestoreViewTest : UiTestPrerequisites() {
it.assertIsFocused()
}
val inputMethodManager = ApplicationProvider.getApplicationContext<Context>().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val inputMethodManager = getAppContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
assertTrue(inputMethodManager.isAcceptingText)
}

View File

@ -1,10 +1,9 @@
package co.electriccoin.zcash.ui.screen.scan.util
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.ui.test.getAppContext
import org.junit.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
@ -14,13 +13,13 @@ class SettingsUtilTest {
companion object {
val SETTINGS_URI = SettingsUtil.SETTINGS_URI_PREFIX +
ApplicationProvider.getApplicationContext<Context>().packageName
getAppContext().packageName
}
@Test
@SmallTest
fun check_intent_to_settings() {
val intent = SettingsUtil.newSettingsIntent(ApplicationProvider.getApplicationContext<Context>().packageName)
val intent = SettingsUtil.newSettingsIntent(getAppContext().packageName)
assertNotNull(intent)
assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, intent.action)
assertContains(intent.categories, Intent.CATEGORY_DEFAULT)

View File

@ -1,11 +1,11 @@
package co.electriccoin.zcash.ui.screen.send.ext
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import cash.z.ecc.sdk.fixture.WalletAddressFixture
import co.electriccoin.zcash.ui.test.getAppContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import org.junit.Test
import kotlin.test.assertEquals
class WalletAddressExtTest {
@ -14,7 +14,7 @@ class WalletAddressExtTest {
@SmallTest
@OptIn(ExperimentalCoroutinesApi::class)
fun testAbbreviated() = runTest {
val actual = WalletAddressFixture.shieldedSapling().abbreviated(ApplicationProvider.getApplicationContext())
val actual = WalletAddressFixture.shieldedSapling().abbreviated(getAppContext())
// TODO [#248]: The expected value should probably be reversed if the locale is RTL
val expected = "ztest…rxnwg"

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.ui.screen.support.model
import androidx.test.core.app.ApplicationProvider
import co.electriccoin.zcash.ui.test.getAppContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import org.junit.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ -11,7 +11,7 @@ class SupportInfoTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun filter_time() = runTest {
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
val supportInfo = SupportInfo.new(getAppContext())
val individualExpected = supportInfo.timeInfo.toSupportString()
@ -25,7 +25,7 @@ class SupportInfoTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun filter_app() = runTest {
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
val supportInfo = SupportInfo.new(getAppContext())
val individualExpected = supportInfo.appInfo.toSupportString()
@ -39,7 +39,7 @@ class SupportInfoTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun filter_os() = runTest {
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
val supportInfo = SupportInfo.new(getAppContext())
val individualExpected = supportInfo.operatingSystemInfo.toSupportString()
@ -53,7 +53,7 @@ class SupportInfoTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun filter_device() = runTest {
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
val supportInfo = SupportInfo.new(getAppContext())
val individualExpected = supportInfo.deviceInfo.toSupportString()
@ -67,7 +67,7 @@ class SupportInfoTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun filter_crash() = runTest {
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
val supportInfo = SupportInfo.new(getAppContext())
val individualExpected = supportInfo.crashInfo.toCrashSupportString()
@ -81,7 +81,7 @@ class SupportInfoTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun filter_environment() = runTest {
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
val supportInfo = SupportInfo.new(getAppContext())
val individualExpected = supportInfo.environmentInfo.toSupportString()
@ -95,7 +95,7 @@ class SupportInfoTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun filter_permission() = runTest {
val supportInfo = SupportInfo.new(ApplicationProvider.getApplicationContext())
val supportInfo = SupportInfo.new(getAppContext())
val individualExpected = supportInfo.permissionInfo.toPermissionSupportString()

View File

@ -1,7 +1,8 @@
package co.electriccoin.zcash.ui.screen.update.fixture
import androidx.test.filters.SmallTest
import kotlin.test.Test
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import org.junit.Test
import kotlin.test.assertEquals
class UpdateInfoFixtureTest {

View File

@ -6,8 +6,8 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.update.view.UpdateViewTestSetup

View File

@ -1,9 +1,8 @@
package co.electriccoin.zcash.ui.screen.update.util
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.ui.test.getAppContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
@ -13,13 +12,13 @@ class PlayStoreUtilTest {
companion object {
val PLAY_STORE_URI = PlayStoreUtil.PLAY_STORE_APP_URI +
ApplicationProvider.getApplicationContext<Context>().packageName
getAppContext().packageName
}
@Test
@SmallTest
fun check_intent_for_store() {
val intent = PlayStoreUtil.newActivityIntent(ApplicationProvider.getApplicationContext())
val intent = PlayStoreUtil.newActivityIntent(getAppContext())
assertNotNull(intent)
assertEquals(intent.action, Intent.ACTION_VIEW)
assertContains(PLAY_STORE_URI, intent.data.toString())

View File

@ -4,12 +4,12 @@ import android.app.Activity
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.MediumTest
import cash.z.ecc.android.sdk.ext.onFirst
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerImp
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.test.getAppContext
import com.google.android.play.core.install.model.ActivityResult
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@ -26,7 +26,7 @@ class AppUpdateCheckerImpTest {
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
companion object {
val context: Context = ApplicationProvider.getApplicationContext()
val context: Context = getAppContext()
val updateChecker = AppUpdateCheckerImp.new()
}

View File

@ -10,9 +10,9 @@ import androidx.test.espresso.Espresso
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.UpdateTag
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.test.getStringResource

View File

@ -4,6 +4,8 @@ import android.content.Context
import androidx.annotation.StringRes
import androidx.test.core.app.ApplicationProvider
fun getStringResource(@StringRes resId: Int) = ApplicationProvider.getApplicationContext<Context>().getString(resId)
fun getAppContext(): Context = ApplicationProvider.getApplicationContext()
fun getStringResourceWithArgs(@StringRes resId: Int, vararg formatArgs: String) = ApplicationProvider.getApplicationContext<Context>().getString(resId, *formatArgs)
fun getStringResource(@StringRes resId: Int) = getAppContext().getString(resId)
fun getStringResourceWithArgs(@StringRes resId: Int, vararg formatArgs: String) = getAppContext().getString(resId, *formatArgs)

View File

@ -24,6 +24,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import cash.z.ecc.sdk.model.ZecRequest
import cash.z.ecc.sdk.send
import co.electriccoin.zcash.ui.design.compat.FontCompat
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Override
@ -82,7 +83,14 @@ class MainActivity : ComponentActivity() {
setupSplashScreen()
setupUiContent()
if (FontCompat.isFontPrefetchNeeded()) {
lifecycleScope.launch {
FontCompat.prefetchFontsLegacy(applicationContext)
setupUiContent()
}
} else {
setupUiContent()
}
}
private fun setupSplashScreen() {

View File

@ -0,0 +1,7 @@
@file:Suppress("ktlint:filename")
package co.electriccoin.zcash.ui.common
import androidx.compose.ui.text.intl.Locale
fun Locale.toKotlinLocale() = cash.z.ecc.sdk.ext.ui.model.Locale(language, region, script)

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.screen.update.fixture
package co.electriccoin.zcash.ui.fixture
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo

View File

@ -4,18 +4,38 @@ import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.PercentDecimal
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import co.electriccoin.zcash.ui.screen.home.viewmodel.SynchronizerError
@Suppress("MagicNumber")
object WalletSnapshotFixture {
val STATUS = Synchronizer.Status.SYNCED
val PROGRESS = PercentDecimal.ZERO_PERCENT
val TRANSPARENT_BALANCE: WalletBalance = WalletBalance(Zatoshi(8), Zatoshi(1))
val ORCHARD_BALANCE: WalletBalance = WalletBalance(Zatoshi(5), Zatoshi(2))
val SAPLING_BALANCE: WalletBalance = WalletBalance(Zatoshi(4), Zatoshi(4))
// Should fill in with non-empty values for better example values in tests and UI previews
@Suppress("LongParameterList")
fun new(
status: Synchronizer.Status = Synchronizer.Status.SYNCED,
status: Synchronizer.Status = STATUS,
processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(),
orchardBalance: WalletBalance = WalletBalance(Zatoshi(5), Zatoshi(2)),
saplingBalance: WalletBalance = WalletBalance(Zatoshi(4), Zatoshi(4)),
transparentBalance: WalletBalance = WalletBalance(Zatoshi(8), Zatoshi(1)),
pendingCount: Int = 0
) = WalletSnapshot(status, processorInfo, orchardBalance, saplingBalance, transparentBalance, pendingCount)
orchardBalance: WalletBalance = ORCHARD_BALANCE,
saplingBalance: WalletBalance = SAPLING_BALANCE,
transparentBalance: WalletBalance = TRANSPARENT_BALANCE,
pendingCount: Int = 0,
progress: PercentDecimal = PROGRESS,
synchronizerError: SynchronizerError? = null
) = WalletSnapshot(
status,
processorInfo,
orchardBalance,
saplingBalance,
transparentBalance,
pendingCount,
progress,
synchronizerError
)
}

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.ui.preference
import cash.z.ecc.sdk.model.FiatCurrency
import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.Key
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
data class FiatCurrencyPreferenceDefault(
override val key: Key
) : PreferenceDefault<FiatCurrency> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
preferenceProvider.getString(key)?.let { FiatCurrency(it) } ?: FiatCurrency("USD")
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: FiatCurrency
) = preferenceProvider.putString(key, newValue.code)
}

View File

@ -9,4 +9,9 @@ object StandardPreferenceKeys {
* Whether the user has completed the backup flow for a newly created wallet.
*/
val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(Key("is_user_backup_complete"), false)
/**
* The fiat currency that the user prefers.
*/
val PREFERRED_FIAT_CURRENCY = FiatCurrencyPreferenceDefault(Key("preferred_fiat_currency_code"))
}

View File

@ -13,8 +13,11 @@ import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.screen.backup.viewmodel.BackupViewModel
import co.electriccoin.zcash.ui.screen.home.view.Home
import co.electriccoin.zcash.ui.screen.home.viewmodel.CheckUpdateViewModel
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerImp
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
@Composable
internal fun MainActivity.WrapHome(
@ -40,6 +43,17 @@ internal fun WrapHome(
goSend: () -> Unit,
goRequest: () -> Unit
) {
// we want to show information about app update, if available
val checkUpdateViewModel by activity.viewModels<CheckUpdateViewModel> {
CheckUpdateViewModel.CheckUpdateViewModelFactory(
activity.application,
AppUpdateCheckerImp.new()
)
}
val updateAvailable = checkUpdateViewModel.updateInfo.collectAsState().value.let {
it?.appUpdateInfo != null && it.state == UpdateState.Prepared
}
val walletViewModel by activity.viewModels<WalletViewModel>()
val walletSnapshot = walletViewModel.walletSnapshot.collectAsState().value
@ -76,7 +90,8 @@ internal fun WrapHome(
val backupViewModel by activity.viewModels<BackupViewModel>()
backupViewModel.backupState.goToBeginning()
}
},
updateAvailable = updateAvailable
)
activity.reportFullyDrawn()

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.ui.screen.home
/**
* These are only used for automated testing.
*/
object HomeTag {
const val STATUS_VIEWS = "status_views"
const val PROGRESS = "progress_bar"
const val SINGLE_LINE_TEXT = "single_line_text"
}

View File

@ -0,0 +1,113 @@
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.sdk.ext.ui.model.FiatCurrencyConversionRateState
import cash.z.ecc.sdk.ext.ui.model.MonetarySeparators
import cash.z.ecc.sdk.ext.ui.model.toZecString
import cash.z.ecc.sdk.ext.ui.toFiatCurrencyState
import cash.z.ecc.sdk.model.PercentDecimal
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.toKotlinLocale
import kotlin.math.roundToInt
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] https://github.com/zcash/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.PREPARING,
Synchronizer.Status.DOWNLOADING,
Synchronizer.Status.VALIDATING -> {
progress = walletSnapshot.progress
val progressPercent = (walletSnapshot.progress.decimal * 100).roundToInt()
// 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, progressPercent)
}
Synchronizer.Status.SCANNING -> {
// SDK provides us only one progress, which keeps on 100 in the scanning state
progress = PercentDecimal.ONE_HUNDRED_PERCENT
statusText = context.getString(R.string.home_status_syncing_catchup)
}
Synchronizer.Status.SYNCED,
Synchronizer.Status.ENHANCING -> {
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

@ -5,6 +5,8 @@ import cash.z.ecc.android.sdk.block.CompactBlockProcessor
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.sdk.model.PercentDecimal
import co.electriccoin.zcash.ui.screen.home.viewmodel.SynchronizerError
// TODO [#292]: Should be moved to SDK-EXT-UI module.
data class WalletSnapshot(
@ -13,7 +15,9 @@ data class WalletSnapshot(
val orchardBalance: WalletBalance,
val saplingBalance: WalletBalance,
val transparentBalance: WalletBalance,
val pendingCount: Int
val pendingCount: Int,
val progress: PercentDecimal,
val synchronizerError: SynchronizerError?
) {
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasFunds = saplingBalance.available.value >

View File

@ -1,17 +1,25 @@
package co.electriccoin.zcash.ui.screen.home.view
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@ -25,24 +33,32 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.db.entity.Transaction
import cash.z.ecc.sdk.ext.ui.model.toZecString
import cash.z.ecc.sdk.ext.ui.model.FiatCurrencyConversionRateState
import cash.z.ecc.sdk.model.PercentDecimal
import co.electriccoin.zcash.crash.android.CrashReporter
import co.electriccoin.zcash.ui.R
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 co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.Header
import co.electriccoin.zcash.ui.design.component.HeaderWithZecIcon
import co.electriccoin.zcash.ui.design.component.PrimaryButton
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.home.HomeTag
import co.electriccoin.zcash.ui.screen.home.model.WalletDisplayValues
import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import co.electriccoin.zcash.ui.screen.home.model.totalBalance
import java.lang.RuntimeException
@Preview
@Composable
@ -56,9 +72,10 @@ fun ComposablePreview() {
goProfile = {},
goSend = {},
goRequest = {},
isDebugMenuEnabled = false,
resetSdk = {},
wipeEntireWallet = {}
wipeEntireWallet = {},
isDebugMenuEnabled = false,
updateAvailable = false
)
}
}
@ -76,7 +93,8 @@ fun Home(
goRequest: () -> Unit,
resetSdk: () -> Unit,
wipeEntireWallet: () -> Unit,
isDebugMenuEnabled: Boolean
isDebugMenuEnabled: Boolean,
updateAvailable: Boolean
) {
Scaffold(topBar = {
HomeTopAppBar(isDebugMenuEnabled, resetSdk, wipeEntireWallet)
@ -88,7 +106,8 @@ fun Home(
goScan = goScan,
goProfile = goProfile,
goSend = goSend,
goRequest = goRequest
goRequest = goRequest,
updateAvailable = updateAvailable
)
}
}
@ -163,9 +182,10 @@ private fun HomeMainContent(
goScan: () -> Unit,
goProfile: () -> Unit,
goSend: () -> Unit,
goRequest: () -> Unit
goRequest: () -> Unit,
updateAvailable: Boolean
) {
Column {
Column(Modifier.verticalScroll(rememberScrollState())) {
Row(
Modifier
.fillMaxWidth()
@ -189,33 +209,133 @@ private fun HomeMainContent(
)
}
}
Status(walletSnapshot)
Status(walletSnapshot, updateAvailable)
Spacer(modifier = Modifier.height(24.dp))
PrimaryButton(onClick = goSend, text = stringResource(R.string.home_button_send))
TertiaryButton(onClick = goRequest, text = stringResource(R.string.home_button_request))
History(transactionHistory)
}
}
@Composable
private fun Status(walletSnapshot: WalletSnapshot) {
Column(Modifier.fillMaxWidth()) {
Header(text = walletSnapshot.totalBalance().toZecString())
Body(
text = stringResource(
id = R.string.home_status_shielding_format,
walletSnapshot.saplingBalance.total.toZecString()
)
)
@Suppress("LongMethod", "MagicNumber")
private fun Status(
walletSnapshot: WalletSnapshot,
updateAvailable: Boolean
) {
val configuration = LocalConfiguration.current
val contentSizeRatioRatio = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
0.45f
} else {
0.9f
}
}
@Composable
private fun History(transactionHistory: List<Transaction>) {
Column(Modifier.fillMaxWidth()) {
LazyColumn {
items(transactionHistory) {
Text(it.toString())
// UI parts sizes
val progressCircleStroke = 12.dp
val progressCirclePadding = progressCircleStroke + 6.dp
val contentPadding = progressCircleStroke + progressCirclePadding + 10.dp
val walletDisplayValues = WalletDisplayValues.getNextValues(
LocalContext.current,
walletSnapshot,
updateAvailable
)
// wrapper box
Box(
Modifier
.fillMaxWidth()
.testTag(HomeTag.STATUS_VIEWS),
contentAlignment = Alignment.Center
) {
// relatively sized box
Box(
modifier = Modifier
.fillMaxWidth(contentSizeRatioRatio)
.aspectRatio(1f),
contentAlignment = Alignment.Center
) {
// progress circle
if (walletDisplayValues.progress.decimal > PercentDecimal.ZERO_PERCENT.decimal) {
CircularProgressIndicator(
progress = walletDisplayValues.progress.decimal,
color = Color.Gray,
strokeWidth = progressCircleStroke,
modifier = Modifier
.matchParentSize()
.padding(progressCirclePadding)
.testTag(HomeTag.PROGRESS)
)
}
// texts
Column(
modifier = Modifier
.padding(contentPadding)
.wrapContentSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(24.dp))
if (walletDisplayValues.zecAmountText.isNotEmpty()) {
HeaderWithZecIcon(amount = walletDisplayValues.zecAmountText)
}
Spacer(modifier = Modifier.height(8.dp))
when (walletDisplayValues.fiatCurrencyAmountState) {
is FiatCurrencyConversionRateState.Current -> {
BodyWithFiatCurrencySymbol(
amount = walletDisplayValues.fiatCurrencyAmountText
)
}
is FiatCurrencyConversionRateState.Stale -> {
// Note: we should show information about staleness too
BodyWithFiatCurrencySymbol(
amount = walletDisplayValues.fiatCurrencyAmountText
)
}
is FiatCurrencyConversionRateState.Unavailable -> {
Body(text = walletDisplayValues.fiatCurrencyAmountText)
}
}
Spacer(modifier = Modifier.height(24.dp))
if (walletDisplayValues.statusText.isNotEmpty()) {
Body(
text = walletDisplayValues.statusText,
modifier = Modifier.testTag(HomeTag.SINGLE_LINE_TEXT)
)
}
}
}
}
}
@Composable
@Suppress("MagicNumber")
private fun History(transactionHistory: List<Transaction>) {
if (transactionHistory.isEmpty()) {
return
}
// here we need to use a fixed height to avoid nested columns vertical scrolling problem
// we'll refactor this part to a dedicated bottom sheet later
val historyPart = LocalConfiguration.current.screenHeightDp / 3
LazyColumn(
Modifier
.fillMaxWidth()
.height(historyPart.dp)
) {
items(transactionHistory) {
Text(it.toString())
}
}
}

View File

@ -14,8 +14,11 @@ import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.sdk.model.FiatCurrency
import cash.z.ecc.sdk.model.PercentDecimal
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.sdk.model.WalletAddresses
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceKeys
import co.electriccoin.zcash.ui.preference.EncryptedPreferenceSingleton
@ -25,9 +28,12 @@ import co.electriccoin.zcash.ui.screen.home.model.WalletSnapshot
import co.electriccoin.zcash.work.WorkIds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
@ -63,6 +69,18 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
null
)
/**
* A flow of the user's preferred fiat currency.
*/
val preferredFiatCurrency: StateFlow<FiatCurrency?> = flow<FiatCurrency?> {
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
emitAll(StandardPreferenceKeys.PREFERRED_FIAT_CURRENCY.observe(preferenceProvider))
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
/**
* A flow of whether a backup of the user's wallet has been performed.
*/
@ -241,6 +259,68 @@ sealed class SecretState {
class Ready(val persistableWallet: PersistableWallet) : SecretState()
}
/**
* Represents all kind of Synchronizer errors
*/
// TODO [#529] https://github.com/zcash/secant-android-wallet/issues/529
sealed class SynchronizerError {
abstract fun getCauseMessage(): String?
class Critical(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Processor(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Submission(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Setup(val error: Throwable?) : SynchronizerError() {
override fun getCauseMessage(): String? = error?.localizedMessage
}
class Chain(val x: Int, val y: Int) : SynchronizerError() {
override fun getCauseMessage(): String = "$x, $y"
}
}
private fun Synchronizer.toCommonError(): Flow<SynchronizerError?> = callbackFlow {
// just for initial default value emit
trySend(null)
onCriticalErrorHandler = {
Twig.error { "WALLET - Error Critical: $it" }
trySend(SynchronizerError.Critical(it))
false
}
onProcessorErrorHandler = {
Twig.error { "WALLET - Error Processor: $it" }
trySend(SynchronizerError.Processor(it))
false
}
onSubmissionErrorHandler = {
Twig.error { "WALLET - Error Submission: $it" }
trySend(SynchronizerError.Submission(it))
false
}
onSetupErrorHandler = {
Twig.error { "WALLET - Error Setup: $it" }
trySend(SynchronizerError.Setup(it))
false
}
onChainErrorHandler = { x, y ->
Twig.error { "WALLET - Error Chain: $x, $y" }
trySend(SynchronizerError.Chain(x, y))
}
awaitClose {
// nothing to close here
}
}
// No good way around needing magic numbers for the indices
@Suppress("MagicNumber")
private fun Synchronizer.toWalletSnapshot() =
@ -250,7 +330,9 @@ private fun Synchronizer.toWalletSnapshot() =
orchardBalances, // 2
saplingBalances, // 3
transparentBalances, // 4
pendingTransactions.distinctUntilChanged() // 5
pendingTransactions.distinctUntilChanged(), // 5
progress, // 6
toCommonError() // 7
) { flows ->
val pendingCount = (flows[5] as List<*>)
.filterIsInstance(PendingTransaction::class.java)
@ -261,13 +343,22 @@ private fun Synchronizer.toWalletSnapshot() =
val saplingBalance = flows[3] as WalletBalance?
val transparentBalance = flows[4] as WalletBalance?
val progressPercentDecimal = (flows[6] as Int).let { value ->
if (value > PercentDecimal.MAX || value < PercentDecimal.MIN) {
PercentDecimal.ZERO_PERCENT
}
PercentDecimal((flows[6] as Int) / 100f)
}
WalletSnapshot(
flows[0] as Synchronizer.Status,
flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0)),
saplingBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0)),
transparentBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0)),
pendingCount
pendingCount,
progressPercentDecimal,
flows[7] as SynchronizerError?
)
}

View File

@ -33,8 +33,8 @@ import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Reference
import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.UpdateTag
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState

View File

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="59dp"
android:height="105dp"
android:viewportWidth="59"
android:viewportHeight="105">
<path
android:fillColor="#FF000000"
android:pathData="M59,26.586V13.911H36.429V0H22.567V13.911H0V30.7H34.987L6.375,70.258 0,78.4V91.074H22.567v13.867H24.23V105H34.767v-0.059h1.663V91.074H59V74.284H24.01L52.621,34.727 59,26.586Z"/>
</vector>

View File

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

View File

@ -2,6 +2,22 @@
<string name="home_scan_content_description">Scan</string>
<string name="home_profile_content_description">Profile</string>
<string name="home_button_send">Send</string>
<string name="home_status_shielding_format" formatted="true">Shielding <xliff:g id="shielded_amount" example=".023">%1$s</xliff:g></string>
<string name="home_button_request">Request ZEC</string>
<string name="home_status_syncing_format" formatted="true">Syncing - <xliff:g id="synced_percent" example="50">%1$d</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>
</resources>