[#1568] Integrations screen (#1637)

* [#1568] Integrations screen

Closes #1568

* [#1568] Icon added to integrations list item

Closes #1568

* [#1568] Test hotfix

Closes #1568

* [#1568] Code cleanup

Closes #1568

* [#1568] Code cleanup

Closes #1568
This commit is contained in:
Milan 2024-10-17 13:07:50 +02:00 committed by GitHub
parent 0e67d826d3
commit d6f630cab8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1000 additions and 533 deletions

View File

@ -16,6 +16,7 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
### Added
- Address book local and remote storage support
- New Integrations screen in settings
- New QR Code detail screen has been added
- The new Request ZEC screens have been added. They provide a way to build ZIP 321 Uri consisting of the amount,
message, and receiver address and then creates a QR code image of it.

View File

@ -18,6 +18,7 @@ directly impact users rather than highlighting other key architectural updates.*
- Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
### Added
- New Integrations screen in settings
- Address book local and remote storage support
- New QR Code detail screen has been added
- The new Request ZEC screens have been added. They provide a way to build ZIP 321 Uri consisting of the amount, message, and receiver address and then creates a QR code image of it.

View File

@ -34,11 +34,14 @@ import co.electriccoin.zcash.ui.design.util.StringResource
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
fun ZashiSettingsListItem(
text: String,
@DrawableRes icon: Int,
titleIcons: ImmutableList<Int> = persistentListOf(),
subtitle: String? = null,
isEnabled: Boolean = true,
onClick: () -> Unit
@ -49,22 +52,20 @@ fun ZashiSettingsListItem(
text = stringRes(text),
subtitle = subtitle?.let { stringRes(it) },
isEnabled = isEnabled,
onClick = onClick
onClick = onClick,
icon = icon,
titleIcons = titleIcons
),
icon = icon,
)
}
@Composable
fun ZashiSettingsListItem(
state: ZashiSettingsListItemState,
@DrawableRes icon: Int
) {
fun ZashiSettingsListItem(state: ZashiSettingsListItemState) {
ZashiSettingsListItem(
leading = { modifier ->
ZashiSettingsListLeadingItem(
modifier = modifier,
icon = icon,
icon = state.icon,
contentDescription = state.text.getValue()
)
},
@ -72,7 +73,8 @@ fun ZashiSettingsListItem(
ZashiSettingsListContentItem(
modifier = modifier,
text = state.text.getValue(),
subtitle = state.subtitle?.getValue()
subtitle = state.subtitle?.getValue(),
titleIcons = state.titleIcons
)
},
trailing = { modifier ->
@ -127,17 +129,28 @@ fun ZashiSettingsListTrailingItem(
fun ZashiSettingsListContentItem(
text: String,
subtitle: String?,
titleIcons: ImmutableList<Int>,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
) {
Text(
text = text,
style = ZashiTypography.textMd,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
Row {
Text(
text = text,
style = ZashiTypography.textMd,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary
)
titleIcons.forEach {
Spacer(Modifier.width(6.dp))
Image(
modifier = Modifier.size(20.dp),
painter = painterResource(it),
contentDescription = null,
)
}
}
subtitle?.let {
Spacer(modifier = Modifier.height(2.dp))
Text(
@ -185,12 +198,13 @@ fun ZashiSettingsListItem(
data class ZashiSettingsListItemState(
val text: StringResource,
@DrawableRes val icon: Int,
val subtitle: StringResource? = null,
val titleIcons: ImmutableList<Int> = persistentListOf(),
val isEnabled: Boolean = true,
val onClick: () -> Unit = {},
)
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun EnabledPreview() =
@ -200,12 +214,12 @@ private fun EnabledPreview() =
text = "Test",
subtitle = "Subtitle",
icon = R.drawable.ic_radio_button_checked,
onClick = {}
onClick = {},
titleIcons = persistentListOf(R.drawable.ic_radio_button_checked)
)
}
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun DisabledPreview() =
@ -216,7 +230,7 @@ private fun DisabledPreview() =
subtitle = "Subtitle",
icon = R.drawable.ic_radio_button_checked,
isEnabled = false,
onClick = {}
onClick = {},
)
}
}

View File

@ -44,6 +44,7 @@ android {
"src/main/res/ui/export_data",
"src/main/res/ui/home",
"src/main/res/ui/choose_server",
"src/main/res/ui/integrations",
"src/main/res/ui/new_wallet_recovery",
"src/main/res/ui/onboarding",
"src/main/res/ui/qr_code",

View File

@ -1,7 +1,9 @@
package co.electriccoin.zcash.ui.screen.settings
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.settings.model.SettingsState
@ -120,7 +122,12 @@ class SettingsViewTestSetup(
},
onAddressBookClick = {
onAddressBookCount.incrementAndGet()
}
},
integrations =
ZashiSettingsListItemState(
stringRes("Integrations"),
R.drawable.ic_settings_integrations,
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)

View File

@ -2,8 +2,12 @@ package co.electriccoin.zcash.di
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.AddressBookRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.BalanceRepository
import co.electriccoin.zcash.ui.common.repository.BalanceRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepository
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepositoryImpl
import org.koin.core.module.dsl.singleOf
@ -14,5 +18,7 @@ val repositoryModule =
module {
singleOf(::WalletRepositoryImpl) bind WalletRepository::class
singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class
singleOf(::ExchangeRateRepositoryImpl) bind ExchangeRateRepository::class
singleOf(::BalanceRepositoryImpl) bind BalanceRepository::class
singleOf(::AddressBookRepositoryImpl) bind AddressBookRepository::class
}

View File

@ -6,8 +6,10 @@ import co.electriccoin.zcash.ui.common.usecase.GetAddressesUseCase
import co.electriccoin.zcash.ui.common.usecase.GetContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSpendingKeyUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase
@ -15,6 +17,7 @@ import co.electriccoin.zcash.ui.common.usecase.ObserveContactPickedUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase
import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
@ -53,6 +56,9 @@ val useCaseModule =
singleOf(::ObserveContactPickedUseCase)
singleOf(::GetAddressesUseCase)
singleOf(::CopyToClipboardUseCase)
singleOf(::ObserveWalletStateUseCase)
singleOf(::IsCoinbaseAvailableUseCase)
singleOf(::GetSpendingKeyUseCase)
singleOf(::ShareImageUseCase)
singleOf(::Zip321BuildUriUseCase)
}

View File

@ -11,6 +11,7 @@ import co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel.AdvancedSettin
import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
@ -66,5 +67,6 @@ val viewModelModule =
viewModelOf(::UpdateContactViewModel)
viewModelOf(::ReceiveViewModel)
viewModelOf(::QrCodeViewModel)
viewModelOf(::IntegrationsViewModel)
viewModelOf(::RequestViewModel)
}

View File

@ -35,6 +35,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.DELETE_WALLET
import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
@ -69,6 +70,7 @@ import co.electriccoin.zcash.ui.screen.exchangerate.optin.AndroidExchangeRateOpt
import co.electriccoin.zcash.ui.screen.exchangerate.settings.AndroidSettingsExchangeRateOptIn
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.integrations.WrapIntegrations
import co.electriccoin.zcash.ui.screen.qrcode.WrapQrCode
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest
@ -225,6 +227,9 @@ internal fun MainActivity.Navigation() {
composable(WHATS_NEW) {
WrapWhatsNew()
}
composable(INTEGRATIONS) {
WrapIntegrations()
}
composable(EXCHANGE_RATE_OPT_IN) {
AndroidExchangeRateOptIn()
}
@ -539,6 +544,7 @@ object NavigationTargets {
const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in"
const val SUPPORT = "support"
const val WHATS_NEW = "whats_new"
const val INTEGRATIONS = "integrations"
}
object NavigationArgs {

View File

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

View File

@ -0,0 +1,227 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.ObserveFiatCurrencyResult
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.preference.model.entry.NullableBooleanPreferenceDefault
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.common.wallet.RefreshLock
import co.electriccoin.zcash.ui.common.wallet.StaleLock
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.minutes
interface ExchangeRateRepository {
val navigationCommand: MutableSharedFlow<String>
val backNavigationCommand: MutableSharedFlow<Unit>
val isExchangeRateUsdOptedIn: StateFlow<Boolean?>
val state: StateFlow<ExchangeRateState>
fun optInExchangeRateUsd(optIn: Boolean)
fun dismissOptInExchangeRateUsd()
fun refreshExchangeRateUsd()
}
class ExchangeRateRepositoryImpl(
private val walletRepository: WalletRepository,
private val standardPreferenceProvider: StandardPreferenceProvider,
) : ExchangeRateRepository {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override val isExchangeRateUsdOptedIn: StateFlow<Boolean?> =
nullableBooleanStateFlow(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN)
@OptIn(ExperimentalCoroutinesApi::class)
private val exchangeRateUsdInternal =
isExchangeRateUsdOptedIn.flatMapLatest { optedIn ->
if (optedIn == true) {
walletRepository.synchronizer
.filterNotNull()
.flatMapLatest { synchronizer ->
synchronizer.exchangeRateUsd
}
} else {
flowOf(ObserveFiatCurrencyResult(isLoading = false, currencyConversion = null))
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(USD_EXCHANGE_REFRESH_LOCK_THRESHOLD),
initialValue = ObserveFiatCurrencyResult(isLoading = false, currencyConversion = null)
)
private val usdExchangeRateTimestamp =
exchangeRateUsdInternal
.map {
it.currencyConversion?.timestamp
}
.distinctUntilChanged()
private val refreshExchangeRateUsdLock =
RefreshLock(
timestampToObserve = usdExchangeRateTimestamp,
lockDuration = USD_EXCHANGE_REFRESH_LOCK_THRESHOLD
)
private val staleExchangeRateUsdLock =
StaleLock(
timestampToObserve = usdExchangeRateTimestamp,
lockDuration = USD_EXCHANGE_STALE_LOCK_THRESHOLD,
onRefresh = { refreshExchangeRateUsdInternal().join() }
)
private var lastExchangeRateUsdValue: ExchangeRateState = ExchangeRateState.OptedOut
override val state: StateFlow<ExchangeRateState> =
channelFlow {
combine(
isExchangeRateUsdOptedIn,
exchangeRateUsdInternal,
staleExchangeRateUsdLock.state,
refreshExchangeRateUsdLock.state,
) { isOptedIn, exchangeRate, isStale, isRefreshEnabled ->
lastExchangeRateUsdValue =
when (isOptedIn) {
true ->
when (val lastValue = lastExchangeRateUsdValue) {
is ExchangeRateState.Data ->
lastValue.copy(
isLoading = exchangeRate.isLoading,
isStale = isStale,
isRefreshEnabled = isRefreshEnabled,
currencyConversion = exchangeRate.currencyConversion,
)
ExchangeRateState.OptedOut ->
ExchangeRateState.Data(
isLoading = exchangeRate.isLoading,
isStale = isStale,
isRefreshEnabled = isRefreshEnabled,
currencyConversion = exchangeRate.currencyConversion,
onRefresh = ::refreshExchangeRateUsd
)
is ExchangeRateState.OptIn ->
ExchangeRateState.Data(
isLoading = exchangeRate.isLoading,
isStale = isStale,
isRefreshEnabled = isRefreshEnabled,
currencyConversion = exchangeRate.currencyConversion,
onRefresh = ::refreshExchangeRateUsd
)
}
false -> ExchangeRateState.OptedOut
null ->
ExchangeRateState.OptIn(
onDismissClick = ::dismissWidgetOptInExchangeRateUsd,
onPrimaryClick = ::showOptInExchangeRateUsd
)
}
lastExchangeRateUsdValue
}.distinctUntilChanged()
.onEach {
Twig.info { "[USD] $it" }
send(it)
}
.launchIn(this)
awaitClose {
// do nothing
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(),
initialValue = ExchangeRateState.OptedOut
)
override val navigationCommand = MutableSharedFlow<String>()
override val backNavigationCommand = MutableSharedFlow<Unit>()
override fun refreshExchangeRateUsd() {
refreshExchangeRateUsdInternal()
}
private fun refreshExchangeRateUsdInternal() =
scope.launch {
val synchronizer = walletRepository.synchronizer.filterNotNull().first()
val value = state.value
if (value is ExchangeRateState.Data && value.isRefreshEnabled && !value.isLoading) {
synchronizer.refreshExchangeRateUsd()
}
}
override fun optInExchangeRateUsd(optIn: Boolean) {
scope.launch {
setNullableBooleanPreference(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN, optIn)
backNavigationCommand.emit(Unit)
}
}
override fun dismissOptInExchangeRateUsd() {
scope.launch {
setNullableBooleanPreference(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN, false)
backNavigationCommand.emit(Unit)
}
}
private fun dismissWidgetOptInExchangeRateUsd() {
setNullableBooleanPreference(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN, false)
}
private fun showOptInExchangeRateUsd() =
scope.launch {
navigationCommand.emit(EXCHANGE_RATE_OPT_IN)
}
private fun nullableBooleanStateFlow(default: NullableBooleanPreferenceDefault): StateFlow<Boolean?> =
flow {
emitAll(default.observe(standardPreferenceProvider()))
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
private fun setNullableBooleanPreference(
default: NullableBooleanPreferenceDefault,
newState: Boolean
) {
scope.launch {
default.putValue(standardPreferenceProvider(), newState)
}
}
}
private val USD_EXCHANGE_REFRESH_LOCK_THRESHOLD = 2.minutes
private val USD_EXCHANGE_STALE_LOCK_THRESHOLD = 15.minutes

View File

@ -1,40 +1,58 @@
package co.electriccoin.zcash.ui.common.repository
import android.app.Application
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.FastestServersResult
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
import cash.z.ecc.android.sdk.model.WalletAddresses
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.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.extension.throttle
import co.electriccoin.zcash.ui.common.model.FastestServersState
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
import co.electriccoin.zcash.ui.preference.PersistableWalletPreferenceDefault
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -44,13 +62,31 @@ import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
interface WalletRepository {
/**
* Synchronizer that is retained long enough to survive configuration changes.
*/
val synchronizer: StateFlow<Synchronizer?>
val secretState: StateFlow<SecretState?>
val secretState: StateFlow<SecretState>
val fastestServers: StateFlow<FastestServersState>
val persistableWallet: Flow<PersistableWallet?>
val addresses: StateFlow<WalletAddresses?>
val walletSnapshot: StateFlow<WalletSnapshot?>
val onboardingState: Flow<OnboardingState>
val spendingKey: StateFlow<UnifiedSpendingKey?>
/**
* A flow of the wallet block synchronization state.
*/
val walletRestoringState: StateFlow<WalletRestoringState>
/**
* A flow of the wallet current state information that should be displayed in screens top app bar.
*/
val walletStateInformation: StateFlow<TopAppBarSubTitleState>
fun persistWallet(persistableWallet: PersistableWallet)
@ -82,7 +118,7 @@ class WalletRepositoryImpl(
/**
* A flow of the wallet onboarding state.
*/
private val onboardingState =
override val onboardingState =
flow {
emitAll(
StandardPreferenceKeys.ONBOARDING_STATE.observe(standardPreferenceProvider()).map { persistedNumber ->
@ -122,6 +158,26 @@ class WalletRepositoryImpl(
initialValue = SecretState.Loading
)
override val spendingKey =
secretState
.filterIsInstance<SecretState.Ready>()
.map { it.persistableWallet }
.map {
val bip39Seed =
withContext(Dispatchers.IO) {
Mnemonics.MnemonicCode(it.seedPhrase.joinToString()).toSeed()
}
DerivationTool.getInstance().deriveUnifiedSpendingKey(
seed = bip39Seed,
network = it.network,
account = Account.DEFAULT
)
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
@OptIn(ExperimentalCoroutinesApi::class)
override val fastestServers =
channelFlow {
@ -180,6 +236,63 @@ class WalletRepositoryImpl(
null
)
@OptIn(ExperimentalCoroutinesApi::class)
override val walletSnapshot: StateFlow<WalletSnapshot?> =
synchronizer
.flatMapLatest {
if (null == it) {
flowOf(null)
} else {
it.toWalletSnapshot()
}
}
.throttle(1.seconds)
.stateIn(
scope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
/**
* A flow of the wallet block synchronization state.
*/
override val walletRestoringState: StateFlow<WalletRestoringState> =
flow {
emitAll(
StandardPreferenceKeys.WALLET_RESTORING_STATE
.observe(standardPreferenceProvider()).map { persistedNumber ->
WalletRestoringState.fromNumber(persistedNumber)
}
)
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = WalletRestoringState.NONE
)
@OptIn(ExperimentalCoroutinesApi::class)
override val walletStateInformation: StateFlow<TopAppBarSubTitleState> =
synchronizer
.filterNotNull()
.flatMapLatest { synchronizer ->
combine(
synchronizer.status,
walletRestoringState
) { status: Synchronizer.Status?, walletRestoringState: WalletRestoringState ->
if (Synchronizer.Status.DISCONNECTED == status) {
TopAppBarSubTitleState.Disconnected
} else if (WalletRestoringState.RESTORING == walletRestoringState) {
TopAppBarSubTitleState.Restoring
} else {
TopAppBarSubTitleState.None
}
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = TopAppBarSubTitleState.None
)
/**
* Persists a wallet asynchronously. Clients observe [secretState] to see the side effects.
*/
@ -243,3 +356,74 @@ class WalletRepositoryImpl(
override suspend fun getSynchronizer(): Synchronizer = synchronizer.filterNotNull().first()
}
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() =
combine(
// 0
status,
// 1
processorInfo,
// 2
orchardBalances,
// 3
saplingBalances,
// 4
transparentBalance,
// 5
progress,
// 6
toCommonError()
) { flows ->
val orchardBalance = flows[2] as WalletBalance?
val saplingBalance = flows[3] as WalletBalance?
val transparentBalance = flows[4] as Zatoshi?
val progressPercentDecimal = (flows[5] as PercentDecimal)
WalletSnapshot(
status = flows[0] as Synchronizer.Status,
processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance = orchardBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
saplingBalance = saplingBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
transparentBalance = transparentBalance ?: Zatoshi(0),
progress = progressPercentDecimal,
synchronizerError = flows[6] as SynchronizerError?
)
}

View File

@ -0,0 +1,11 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
class GetSpendingKeyUseCase(
private val walletRepository: WalletRepository
) {
suspend operator fun invoke() = walletRepository.spendingKey.filterNotNull().first()
}

View File

@ -0,0 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
class IsCoinbaseAvailableUseCase(
private val getVersionInfo: GetVersionInfoProvider,
) {
operator fun invoke(): Boolean {
val versionInfo = getVersionInfo()
val isDebug = versionInfo.let { it.isDebuggable && !it.isRunningUnderTestService }
return !versionInfo.isTestnet && (BuildConfig.ZCASH_COINBASE_APP_ID.isNotEmpty() || isDebug)
}
}

View File

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

View File

@ -5,82 +5,48 @@ import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator
import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.Account
import cash.z.ecc.android.sdk.model.BlockHeight
import cash.z.ecc.android.sdk.model.ObserveFiatCurrencyResult
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.WalletAddresses
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.preference.model.entry.NullableBooleanPreferenceDefault
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.NavigationTargets.EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.common.compose.BalanceState
import co.electriccoin.zcash.ui.common.extension.throttle
import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.model.hasChangePending
import co.electriccoin.zcash.ui.common.model.hasValuePending
import co.electriccoin.zcash.ui.common.model.spendableBalance
import co.electriccoin.zcash.ui.common.model.totalBalance
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider
import co.electriccoin.zcash.ui.common.repository.BalanceRepository
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.common.wallet.RefreshLock
import co.electriccoin.zcash.ui.common.wallet.StaleLock
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.account.ext.TransactionOverviewExt
import co.electriccoin.zcash.ui.screen.account.ext.getSortHeight
import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
@ -89,139 +55,30 @@ import kotlin.time.Duration.Companion.seconds
@Suppress("LongParameterList", "TooManyFunctions")
class WalletViewModel(
application: Application,
observeSynchronizer: ObserveSynchronizerUseCase,
balanceRepository: BalanceRepository,
private val walletCoordinator: WalletCoordinator,
private val walletRepository: WalletRepository,
private val exchangeRateRepository: ExchangeRateRepository,
private val encryptedPreferenceProvider: EncryptedPreferenceProvider,
private val standardPreferenceProvider: StandardPreferenceProvider,
private val getAvailableServers: GetDefaultServersProvider
private val getAvailableServers: GetDefaultServersProvider,
) : AndroidViewModel(application) {
val navigationCommand = MutableSharedFlow<String>()
val navigationCommand = exchangeRateRepository.navigationCommand
val backNavigationCommand = MutableSharedFlow<Unit>()
val backNavigationCommand = exchangeRateRepository.backNavigationCommand
/**
* Synchronizer that is retained long enough to survive configuration changes.
*/
val synchronizer = observeSynchronizer()
val synchronizer = walletRepository.synchronizer
/**
* A flow of the wallet block synchronization state.
*/
val walletRestoringState: StateFlow<WalletRestoringState> =
flow {
emitAll(
StandardPreferenceKeys.WALLET_RESTORING_STATE
.observe(standardPreferenceProvider()).map { persistedNumber ->
WalletRestoringState.fromNumber(persistedNumber)
}
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
WalletRestoringState.NONE
)
val walletRestoringState = walletRepository.walletRestoringState
/**
* A flow of the wallet current state information that should be displayed in screens top app bar.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val walletStateInformation: StateFlow<TopAppBarSubTitleState> =
synchronizer
.filterNotNull()
.flatMapLatest { synchronizer ->
combine(
synchronizer.status,
walletRestoringState
) { status: Synchronizer.Status?, walletRestoringState: WalletRestoringState ->
if (Synchronizer.Status.DISCONNECTED == status) {
TopAppBarSubTitleState.Disconnected
} else if (WalletRestoringState.RESTORING == walletRestoringState) {
TopAppBarSubTitleState.Restoring
} else {
TopAppBarSubTitleState.None
}
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
TopAppBarSubTitleState.None
)
val walletStateInformation = walletRepository.walletStateInformation
/**
* A flow of the wallet onboarding state.
*/
private val onboardingState =
flow {
emitAll(
StandardPreferenceKeys.ONBOARDING_STATE
.observe(standardPreferenceProvider()).map { persistedNumber ->
OnboardingState.fromNumber(persistedNumber)
}
)
}
val secretState: StateFlow<SecretState> =
combine(
walletCoordinator.persistableWallet,
onboardingState
) { persistableWallet: PersistableWallet?, onboardingState: OnboardingState ->
when {
onboardingState == OnboardingState.NONE -> SecretState.None
onboardingState == OnboardingState.NEEDS_WARN -> SecretState.NeedsWarning
onboardingState == OnboardingState.NEEDS_BACKUP && persistableWallet != null -> {
SecretState.NeedsBackup(persistableWallet)
}
onboardingState == OnboardingState.READY && persistableWallet != null -> {
SecretState.Ready(persistableWallet)
}
else -> SecretState.None
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
SecretState.Loading
)
val secretState: StateFlow<SecretState> = walletRepository.secretState
// This needs to be refactored once we support pin lock
val spendingKey =
secretState
.filterIsInstance<SecretState.Ready>()
.map { it.persistableWallet }
.map {
val bip39Seed =
withContext(Dispatchers.IO) {
Mnemonics.MnemonicCode(it.seedPhrase.joinToString()).toSeed()
}
DerivationTool.getInstance().deriveUnifiedSpendingKey(
seed = bip39Seed,
network = it.network,
account = Account.DEFAULT
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
val spendingKey = walletRepository.spendingKey
@OptIn(ExperimentalCoroutinesApi::class)
val walletSnapshot: StateFlow<WalletSnapshot?> =
synchronizer
.flatMapLatest {
if (null == it) {
flowOf(null)
} else {
it.toWalletSnapshot()
}
}
.throttle(1.seconds)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
val walletSnapshot: StateFlow<WalletSnapshot?> = walletRepository.walletSnapshot
val addresses: StateFlow<WalletAddresses?> = walletRepository.addresses
@ -277,179 +134,23 @@ class WalletViewModel(
initialValue = TransactionHistorySyncState.Loading
)
val isExchangeRateUsdOptedIn = nullableBooleanStateFlow(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN)
val isExchangeRateUsdOptedIn = exchangeRateRepository.isExchangeRateUsdOptedIn
@OptIn(ExperimentalCoroutinesApi::class)
private val exchangeRateUsdInternal =
isExchangeRateUsdOptedIn.flatMapLatest { optedIn ->
if (optedIn == true) {
synchronizer
.filterNotNull()
.flatMapLatest { synchronizer ->
synchronizer.exchangeRateUsd
}
} else {
flowOf(ObserveFiatCurrencyResult(isLoading = false, currencyConversion = null))
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(USD_EXCHANGE_REFRESH_LOCK_THRESHOLD),
initialValue = ObserveFiatCurrencyResult(isLoading = false, currencyConversion = null)
)
val exchangeRateUsd = exchangeRateRepository.state
private val usdExchangeRateTimestamp =
exchangeRateUsdInternal
.map {
it.currencyConversion?.timestamp
}
.distinctUntilChanged()
val balanceState = balanceRepository.state
private var lastExchangeRateUsdValue: ExchangeRateState = ExchangeRateState.OptedOut
val exchangeRateUsd: StateFlow<ExchangeRateState> =
channelFlow {
combine(
isExchangeRateUsdOptedIn,
exchangeRateUsdInternal,
staleExchangeRateUsdLock.state,
refreshExchangeRateUsdLock.state,
) { isOptedIn, exchangeRate, isStale, isRefreshEnabled ->
lastExchangeRateUsdValue =
when (isOptedIn) {
true ->
when (val lastValue = lastExchangeRateUsdValue) {
is ExchangeRateState.Data ->
lastValue.copy(
isLoading = exchangeRate.isLoading,
isStale = isStale,
isRefreshEnabled = isRefreshEnabled,
currencyConversion = exchangeRate.currencyConversion,
)
ExchangeRateState.OptedOut ->
ExchangeRateState.Data(
isLoading = exchangeRate.isLoading,
isStale = isStale,
isRefreshEnabled = isRefreshEnabled,
currencyConversion = exchangeRate.currencyConversion,
onRefresh = ::refreshExchangeRateUsd
)
is ExchangeRateState.OptIn ->
ExchangeRateState.Data(
isLoading = exchangeRate.isLoading,
isStale = isStale,
isRefreshEnabled = isRefreshEnabled,
currencyConversion = exchangeRate.currencyConversion,
onRefresh = ::refreshExchangeRateUsd
)
}
false -> ExchangeRateState.OptedOut
null ->
ExchangeRateState.OptIn(
onDismissClick = ::dismissWidgetOptInExchangeRateUsd,
onPrimaryClick = ::showOptInExchangeRateUsd
)
}
lastExchangeRateUsdValue
}.distinctUntilChanged()
.onEach {
Twig.info { "[USD] $it" }
send(it)
}
.launchIn(this)
awaitClose {
// do nothing
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = ExchangeRateState.OptedOut
)
/**
* A flow of the wallet balances state used for the UI layer. It's computed form [WalletSnapshot]'s properties
* and provides the result [BalanceState] UI state.
*/
val balanceState: StateFlow<BalanceState> =
combine(
walletSnapshot.filterNotNull(),
exchangeRateUsd,
) { snapshot, exchangeRateUsd ->
when {
// Show the loader only under these conditions:
// - Available balance is currently zero AND total balance is non-zero
// - And wallet has some ChangePending or ValuePending in progress
(
snapshot.spendableBalance().value == 0L &&
snapshot.totalBalance().value > 0L &&
(snapshot.hasChangePending() || snapshot.hasValuePending())
) -> {
BalanceState.Loading(
totalBalance = snapshot.totalBalance(),
exchangeRate = exchangeRateUsd
)
}
else -> {
BalanceState.Available(
totalBalance = snapshot.totalBalance(),
spendableBalance = snapshot.spendableBalance(),
exchangeRate = exchangeRateUsd
)
}
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
BalanceState.None(ExchangeRateState.OptedOut)
)
private val refreshExchangeRateUsdLock =
RefreshLock(
timestampToObserve = usdExchangeRateTimestamp,
lockDuration = USD_EXCHANGE_REFRESH_LOCK_THRESHOLD
)
private val staleExchangeRateUsdLock =
StaleLock(
timestampToObserve = usdExchangeRateTimestamp,
lockDuration = USD_EXCHANGE_STALE_LOCK_THRESHOLD,
onRefresh = { refreshExchangeRateUsd().join() }
)
fun refreshExchangeRateUsd() =
viewModelScope.launch {
val synchronizer = synchronizer.filterNotNull().first()
val value = exchangeRateUsd.value
if (value is ExchangeRateState.Data && value.isRefreshEnabled && !value.isLoading) {
synchronizer.refreshExchangeRateUsd()
}
}
fun optInExchangeRateUsd(optIn: Boolean) =
viewModelScope.launch {
setNullableBooleanPreference(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN, optIn)
backNavigationCommand.emit(Unit)
}
fun dismissOptInExchangeRateUsd() =
viewModelScope.launch {
setNullableBooleanPreference(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN, false)
backNavigationCommand.emit(Unit)
}
private fun dismissWidgetOptInExchangeRateUsd() {
setNullableBooleanPreference(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN, false)
fun refreshExchangeRateUsd() {
exchangeRateRepository.refreshExchangeRateUsd()
}
private fun showOptInExchangeRateUsd() =
viewModelScope.launch {
navigationCommand.emit(EXCHANGE_RATE_OPT_IN)
}
fun optInExchangeRateUsd(optIn: Boolean) {
exchangeRateRepository.optInExchangeRateUsd(optIn)
}
fun dismissOptInExchangeRateUsd() {
exchangeRateRepository.dismissOptInExchangeRateUsd()
}
/**
* Creates a wallet asynchronously and then persists it. Clients observe
@ -574,24 +275,6 @@ class WalletViewModel(
// Nothing to close
}
}
private fun nullableBooleanStateFlow(default: NullableBooleanPreferenceDefault): StateFlow<Boolean?> =
flow {
emitAll(default.observe(standardPreferenceProvider()))
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
null
)
private fun setNullableBooleanPreference(
default: NullableBooleanPreferenceDefault,
newState: Boolean
) {
viewModelScope.launch {
default.putValue(standardPreferenceProvider(), newState)
}
}
}
/**
@ -685,80 +368,6 @@ sealed class SynchronizerError {
}
}
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() =
combine(
// 0
status,
// 1
processorInfo,
// 2
orchardBalances,
// 3
saplingBalances,
// 4
transparentBalance,
// 5
progress,
// 6
toCommonError()
) { flows ->
val orchardBalance = flows[2] as WalletBalance?
val saplingBalance = flows[3] as WalletBalance?
val transparentBalance = flows[4] as Zatoshi?
val progressPercentDecimal = (flows[5] as PercentDecimal)
WalletSnapshot(
status = flows[0] as Synchronizer.Status,
processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo,
orchardBalance = orchardBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
saplingBalance = saplingBalance ?: WalletBalance(Zatoshi(0), Zatoshi(0), Zatoshi(0)),
transparentBalance = transparentBalance ?: Zatoshi(0),
progress = progressPercentDecimal,
synchronizerError = flows[6] as SynchronizerError?
)
}
fun Synchronizer.Status.isSyncing() = this == Synchronizer.Status.SYNCING
fun Synchronizer.Status.isSynced() = this == Synchronizer.Status.SYNCED
private val USD_EXCHANGE_REFRESH_LOCK_THRESHOLD = 2.minutes
private val USD_EXCHANGE_STALE_LOCK_THRESHOLD = 15.minutes

View File

@ -1,7 +1,5 @@
package co.electriccoin.zcash.ui.screen.advancedsettings
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
data class AdvancedSettingsState(
val onBack: () -> Unit,
val onRecoveryPhraseClick: () -> Unit,
@ -9,5 +7,4 @@ data class AdvancedSettingsState(
val onChooseServerClick: () -> Unit,
val onCurrencyConversionClick: () -> Unit,
val onDeleteZashiClick: () -> Unit,
val coinbaseButton: ZashiSettingsListItemState?,
)

View File

@ -2,14 +2,11 @@
package co.electriccoin.zcash.ui.screen.advancedsettings
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.advancedsettings.view.AdvancedSettings
@ -23,7 +20,6 @@ internal fun WrapAdvancedSettings(
goExportPrivateData: () -> Unit,
goSeedRecovery: () -> Unit,
) {
val activity = LocalActivity.current
val navController = LocalNavController.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<AdvancedSettingsViewModel>()
@ -45,18 +41,6 @@ internal fun WrapAdvancedSettings(
}
}
LaunchedEffect(Unit) {
viewModel.coinbaseNavigationCommand.collect { uri ->
val intent =
CustomTabsIntent.Builder()
.setUrlBarHidingEnabled(true)
.setShowTitle(true)
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.build()
intent.launchUrl(activity, Uri.parse(uri))
}
}
LaunchedEffect(Unit) {
viewModel.backNavigationCommand.collect {
navController.popBackStack()

View File

@ -29,14 +29,12 @@ import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsState
import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsTag
@ -70,13 +68,13 @@ fun AdvancedSettings(
) {
ZashiSettingsListItem(
text = stringResource(id = R.string.advanced_settings_recovery),
icon = R.drawable.ic_advanced_settings_recovery orDark R.drawable.ic_advanced_settings_recovery_dark,
icon = R.drawable.ic_advanced_settings_recovery,
onClick = state.onRecoveryPhraseClick
)
ZashiHorizontalDivider()
ZashiSettingsListItem(
text = stringResource(id = R.string.advanced_settings_export),
icon = R.drawable.ic_advanced_settings_export orDark R.drawable.ic_advanced_settings_export_dark,
icon = R.drawable.ic_advanced_settings_export,
onClick = state.onExportPrivateDataClick
)
ZashiHorizontalDivider()
@ -84,7 +82,7 @@ fun AdvancedSettings(
text = stringResource(id = R.string.advanced_settings_choose_server),
icon =
R.drawable.ic_advanced_settings_choose_server orDark
R.drawable.ic_advanced_settings_choose_server_dark,
R.drawable.ic_advanced_settings_choose_server,
onClick = state.onChooseServerClick
)
ZashiHorizontalDivider()
@ -92,16 +90,9 @@ fun AdvancedSettings(
text = stringResource(id = R.string.advanced_settings_currency_conversion),
icon =
R.drawable.ic_advanced_settings_currency_conversion orDark
R.drawable.ic_advanced_settings_currency_conversion_dark,
R.drawable.ic_advanced_settings_currency_conversion,
onClick = state.onCurrencyConversionClick
)
if (state.coinbaseButton != null) {
ZashiHorizontalDivider()
ZashiSettingsListItem(
icon = R.drawable.ic_advanced_settings_coinbase,
state = state.coinbaseButton
)
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.weight(1f))
Row(
@ -171,11 +162,6 @@ private fun AdvancedSettingsPreview() =
onChooseServerClick = {},
onCurrencyConversionClick = {},
onDeleteZashiClick = {},
coinbaseButton =
ZashiSettingsListItemState(
text = stringRes("Coinbase"),
onClick = {}
)
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)

View File

@ -2,27 +2,14 @@ package co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.NavigationTargets
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsState
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class AdvancedSettingsViewModel(
getVersionInfo: GetVersionInfoProvider,
getZcashCurrency: GetZcashCurrencyProvider,
private val getTransparentAddress: GetTransparentAddressUseCase,
) : ViewModel() {
private val forceShowCoinbaseForDebug = getVersionInfo().let { it.isDebuggable && !it.isRunningUnderTestService }
class AdvancedSettingsViewModel : ViewModel() {
val state =
MutableStateFlow(
AdvancedSettingsState(
@ -32,22 +19,11 @@ class AdvancedSettingsViewModel(
onChooseServerClick = ::onChooseServerClick,
onCurrencyConversionClick = ::onCurrencyConversionClick,
onDeleteZashiClick = {},
coinbaseButton =
ZashiSettingsListItemState(
// Set the wallet currency by app build is more future-proof, although we hide it from the UI
// in the Testnet build
text = stringRes(R.string.advanced_settings_coinbase, getZcashCurrency.getLocalizedName()),
onClick = { onBuyWithCoinbaseClicked() }
).takeIf {
!getVersionInfo().isTestnet &&
(BuildConfig.ZCASH_COINBASE_APP_ID.isNotEmpty() || forceShowCoinbaseForDebug)
}
)
).asStateFlow()
val navigationCommand = MutableSharedFlow<String>()
val backNavigationCommand = MutableSharedFlow<Unit>()
val coinbaseNavigationCommand = MutableSharedFlow<String>()
private fun onChooseServerClick() =
viewModelScope.launch {
@ -59,32 +35,6 @@ class AdvancedSettingsViewModel(
navigationCommand.emit(NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN)
}
private fun onBuyWithCoinbaseClicked() {
viewModelScope.launch {
val appId = BuildConfig.ZCASH_COINBASE_APP_ID
when {
appId.isEmpty() && forceShowCoinbaseForDebug ->
coinbaseNavigationCommand.emit("https://www.coinbase.com") // fallback debug url
appId.isEmpty() && forceShowCoinbaseForDebug -> {
// should not happen
}
appId.isNotEmpty() -> {
val address = getTransparentAddress().address
val url =
"https://pay.coinbase.com/buy/select-asset?appId=$appId&addresses={\"${address}\":[\"zcash\"]}"
coinbaseNavigationCommand.emit(url)
}
else -> {
// should not happen
}
}
}
}
fun onBack() =
viewModelScope.launch {
backNavigationCommand.emit(Unit)

View File

@ -0,0 +1,55 @@
package co.electriccoin.zcash.ui.screen.integrations
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.integrations.view.Integrations
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import org.koin.androidx.compose.koinViewModel
@Composable
internal fun WrapIntegrations() {
val activity = LocalActivity.current
val navController = LocalNavController.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<IntegrationsViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle()
val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value
LaunchedEffect(Unit) {
viewModel.backNavigationCommand.collect {
navController.popBackStack()
}
}
LaunchedEffect(Unit) {
viewModel.coinbaseNavigationCommand.collect { uri ->
val intent =
CustomTabsIntent.Builder()
.setUrlBarHidingEnabled(true)
.setShowTitle(true)
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.build()
intent.launchUrl(activity, Uri.parse(uri))
}
}
BackHandler {
viewModel.onBack()
}
state?.let {
Integrations(
state = it,
topAppBarSubTitleState = walletState,
)
}
}

View File

@ -0,0 +1,11 @@
package co.electriccoin.zcash.ui.screen.integrations.model
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.StringResource
data class IntegrationsState(
val version: StringResource,
val coinbase: ZashiSettingsListItemState?,
val disabledInfo: StringResource?,
val onBack: () -> Unit,
)

View File

@ -0,0 +1,149 @@
package co.electriccoin.zcash.ui.screen.integrations.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState
import co.electriccoin.zcash.ui.screen.settings.SettingsTag
@Suppress("LongMethod")
@Composable
fun Integrations(
state: IntegrationsState,
topAppBarSubTitleState: TopAppBarSubTitleState
) {
BlankBgScaffold(
topBar = {
IntegrationsTopAppBar(onBack = state.onBack, subTitleState = topAppBarSubTitleState)
}
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
start = 4.dp,
end = 4.dp
),
) {
state.coinbase?.let {
ZashiSettingsListItem(state = it)
}
state.disabledInfo?.let {
Spacer(modifier = Modifier.height(28.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = R.drawable.ic_advanced_settings_info),
contentDescription = "",
colorFilter = ColorFilter.tint(ZashiColors.Utility.WarningYellow.utilityOrange700)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it.getValue(),
fontSize = 12.sp,
color = ZashiColors.Utility.WarningYellow.utilityOrange700,
)
}
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingMin))
Image(
modifier = Modifier.align(CenterHorizontally),
painter =
painterResource(id = R.drawable.ic_settings_zashi orDark R.drawable.ic_settings_zashi),
contentDescription = ""
)
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier.align(CenterHorizontally),
text = state.version.getValue(),
color = ZashiColors.Text.textTertiary
)
Spacer(modifier = Modifier.height(20.dp))
}
}
}
@Composable
private fun IntegrationsTopAppBar(
onBack: () -> Unit,
subTitleState: TopAppBarSubTitleState
) {
ZashiSmallTopAppBar(
title = stringResource(id = R.string.integrations_title),
subtitle =
when (subTitleState) {
TopAppBarSubTitleState.Disconnected -> stringResource(id = R.string.disconnected_label)
TopAppBarSubTitleState.Restoring -> stringResource(id = R.string.restoring_wallet_label)
TopAppBarSubTitleState.None -> null
},
modifier = Modifier.testTag(SettingsTag.SETTINGS_TOP_APP_BAR),
showTitleLogo = true,
navigationAction = {
ZashiTopAppBarBackNavigation(onBack = onBack)
},
)
}
@Suppress("UnusedPrivateMember")
@PreviewScreens
@Composable
private fun IntegrationSettings() {
ZcashTheme {
Integrations(
state =
IntegrationsState(
version = stringRes("Version 1.2"),
onBack = {},
coinbase =
ZashiSettingsListItemState(
icon = R.drawable.ic_integrations_coinbase,
text = stringRes("Coinbase"),
subtitle = stringRes("Coinbase subtitle"),
) {},
disabledInfo = stringRes("Disabled info"),
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
}

View File

@ -0,0 +1,98 @@
package co.electriccoin.zcash.ui.screen.integrations.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.IsCoinbaseAvailableUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveWalletStateUseCase
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.integrations.model.IntegrationsState
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class IntegrationsViewModel(
getVersionInfo: GetVersionInfoProvider,
getZcashCurrency: GetZcashCurrencyProvider,
observeWalletState: ObserveWalletStateUseCase,
private val getTransparentAddress: GetTransparentAddressUseCase,
private val isCoinbaseAvailable: IsCoinbaseAvailableUseCase,
) : ViewModel() {
val backNavigationCommand = MutableSharedFlow<Unit>()
val coinbaseNavigationCommand = MutableSharedFlow<String>()
private val versionInfo = getVersionInfo()
private val isDebug = versionInfo.let { it.isDebuggable && !it.isRunningUnderTestService }
private val isEnabled =
observeWalletState()
.map {
it != TopAppBarSubTitleState.Restoring
}
val state =
isEnabled.map { isEnabled ->
IntegrationsState(
version = stringRes(R.string.integrations_version, versionInfo.versionName),
coinbase =
ZashiSettingsListItemState(
// Set the wallet currency by app build is more future-proof, although we hide it from the UI
// in the Testnet build
icon = R.drawable.ic_integrations_coinbase,
text = stringRes(R.string.integrations_coinbase, getZcashCurrency.getLocalizedName()),
subtitle =
stringRes(
R.string.integrations_coinbase_subtitle,
getZcashCurrency.getLocalizedName()
),
onClick = ::onBuyWithCoinbaseClicked
).takeIf { isCoinbaseAvailable() },
disabledInfo = stringRes(R.string.integrations_disabled_info).takeIf { isEnabled.not() },
onBack = ::onBack,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = null
)
fun onBack() =
viewModelScope.launch {
backNavigationCommand.emit(Unit)
}
private fun onBuyWithCoinbaseClicked() =
viewModelScope.launch {
val appId = BuildConfig.ZCASH_COINBASE_APP_ID
when {
appId.isEmpty() && isDebug ->
coinbaseNavigationCommand.emit("https://www.coinbase.com") // fallback debug url
appId.isEmpty() && isDebug -> {
// should not happen
}
appId.isNotEmpty() -> {
val address = getTransparentAddress().address
val url =
"https://pay.coinbase.com/buy/select-asset?appId=$appId&addresses={\"${address}\":[\"zcash\"]}"
coinbaseNavigationCommand.emit(url)
}
else -> {
// should not happen
}
}
}
}

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.screen.settings.model
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.StringResource
data class SettingsState(
@ -8,6 +9,7 @@ data class SettingsState(
val settingsTroubleshootingState: SettingsTroubleshootingState?,
val onAddressBookClick: () -> Unit,
val onBack: () -> Unit,
val integrations: ZashiSettingsListItemState,
val onAdvancedSettingsClick: () -> Unit,
val onAboutUsClick: () -> Unit,
val onSendUsFeedbackClick: () -> Unit,

View File

@ -34,17 +34,18 @@ import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.getValue
import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.settings.SettingsTag
import co.electriccoin.zcash.ui.screen.settings.model.SettingsState
import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
@ -82,21 +83,23 @@ fun Settings(
onClick = state.onAddressBookClick
)
ZashiHorizontalDivider()
ZashiSettingsListItem(state = state.integrations)
ZashiHorizontalDivider()
ZashiSettingsListItem(
text = stringResource(id = R.string.settings_advanced_settings),
icon = R.drawable.ic_advanced_settings orDark R.drawable.ic_advanced_settings_dark,
icon = R.drawable.ic_advanced_settings,
onClick = state.onAdvancedSettingsClick
)
ZashiHorizontalDivider()
ZashiSettingsListItem(
text = stringResource(id = R.string.settings_about_us),
icon = R.drawable.ic_settings_info orDark R.drawable.ic_settings_info_dark,
icon = R.drawable.ic_settings_info,
onClick = state.onAboutUsClick
)
ZashiHorizontalDivider()
ZashiSettingsListItem(
text = stringResource(id = R.string.settings_feedback),
icon = R.drawable.ic_settings_feedback orDark R.drawable.ic_settings_feedback_dark,
icon = R.drawable.ic_settings_feedback,
onClick = state.onSendUsFeedbackClick
)
Spacer(modifier = Modifier.weight(1f))
@ -104,7 +107,7 @@ fun Settings(
Image(
modifier = Modifier.align(CenterHorizontally),
painter =
painterResource(id = R.drawable.ic_settings_zashi orDark R.drawable.ic_settings_zashi_dark),
painterResource(id = R.drawable.ic_settings_zashi),
contentDescription = ""
)
Spacer(modifier = Modifier.height(16.dp))
@ -236,7 +239,13 @@ private fun PreviewSettings() {
onAdvancedSettingsClick = {},
onAboutUsClick = {},
onSendUsFeedbackClick = {},
onAddressBookClick = {}
onAddressBookClick = {},
integrations =
ZashiSettingsListItemState(
icon = R.drawable.ic_settings_integrations,
text = stringRes("Integrations"),
titleIcons = persistentListOf(R.drawable.ic_integrations_coinbase)
) {}
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
@ -258,7 +267,13 @@ private fun PreviewSettingsLoading() {
onAdvancedSettingsClick = {},
onAboutUsClick = {},
onSendUsFeedbackClick = {},
onAddressBookClick = {}
onAddressBookClick = {},
integrations =
ZashiSettingsListItemState(
icon = R.drawable.ic_settings_integrations,
text = stringRes("Integrations"),
titleIcons = persistentListOf(R.drawable.ic_integrations_coinbase)
) {}
),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)

View File

@ -7,18 +7,21 @@ import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs
import co.electriccoin.zcash.ui.screen.settings.model.SettingsState
import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState
import co.electriccoin.zcash.ui.screen.settings.model.TroubleshootingItemState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -99,6 +102,13 @@ class SettingsViewModel(
version = stringRes(R.string.settings_version, versionInfo.versionName),
settingsTroubleshootingState = troubleshootingState,
onBack = ::onBack,
integrations =
ZashiSettingsListItemState(
text = stringRes(R.string.settings_integrations),
icon = R.drawable.ic_settings_integrations,
onClick = ::onIntegrationsClick,
titleIcons = persistentListOf(R.drawable.ic_integrations_coinbase)
),
onAdvancedSettingsClick = ::onAdvancedSettingsClick,
onAboutUsClick = ::onAboutUsClick,
onSendUsFeedbackClick = ::onSendUsFeedbackClick,
@ -140,6 +150,11 @@ class SettingsViewModel(
backNavigationCommand.emit(Unit)
}
private fun onIntegrationsClick() =
viewModelScope.launch {
navigationCommand.emit(INTEGRATIONS)
}
private fun onAdvancedSettingsClick() =
viewModelScope.launch {
navigationCommand.emit(ADVANCED_SETTINGS)

View File

@ -4,7 +4,6 @@
<string name="advanced_settings_export">Export Private Data</string>
<string name="advanced_settings_choose_server">Choose a Server</string>
<string name="advanced_settings_currency_conversion">Currency Conversion</string>
<string name="advanced_settings_coinbase">Buy <xliff:g id="currency" example="ZEC">%1$s</xliff:g> with Coinbase</string>
<string name="advanced_settings_info">You will be asked to confirm on the next screen</string>
<string name="advanced_settings_delete_button">Delete Zashi</string>
</resources>

View File

@ -0,0 +1,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="integrations_title">Integrations</string>
<string name="integrations_coinbase">Buy <xliff:g id="currency" example="ZEC">%1$s</xliff:g> with Coinbase</string>
<string name="integrations_coinbase_subtitle">A hassle-free way to buy <xliff:g id="currency" example="ZEC">%1$s</xliff:g> and get it directly into your Zashi wallet.</string>
<string name="integrations_version">Version %s</string>
<string name="integrations_disabled_info">During the Restore process, it is not possible to use payment integrations.</string>
</resources>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M0,20C0,8.954 8.954,0 20,0C31.046,0 40,8.954 40,20C40,31.046 31.046,40 20,40C8.954,40 0,31.046 0,20Z"
android:fillColor="#454243"/>
<path
android:pathData="M24.167,26.667H24C22.6,26.667 21.9,26.667 21.365,26.394C20.895,26.154 20.512,25.772 20.272,25.302C20,24.767 20,24.067 20,22.667V17.333C20,15.933 20,15.233 20.272,14.698C20.512,14.228 20.895,13.845 21.365,13.606C21.9,13.333 22.6,13.333 24,13.333H24.167M24.167,26.667C24.167,27.587 24.913,28.333 25.833,28.333C26.754,28.333 27.5,27.587 27.5,26.667C27.5,25.746 26.754,25 25.833,25C24.913,25 24.167,25.746 24.167,26.667ZM24.167,13.333C24.167,14.254 24.913,15 25.833,15C26.754,15 27.5,14.254 27.5,13.333C27.5,12.413 26.754,11.667 25.833,11.667C24.913,11.667 24.167,12.413 24.167,13.333ZM15.833,20L24.167,20M15.833,20C15.833,20.92 15.087,21.667 14.167,21.667C13.246,21.667 12.5,20.92 12.5,20C12.5,19.079 13.246,18.333 14.167,18.333C15.087,18.333 15.833,19.079 15.833,20ZM24.167,20C24.167,20.92 24.913,21.667 25.833,21.667C26.754,21.667 27.5,20.92 27.5,20C27.5,19.079 26.754,18.333 25.833,18.333C24.913,18.333 24.167,19.079 24.167,20Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#E8E8E8"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M0,20C0,8.954 8.954,0 20,0C31.046,0 40,8.954 40,20C40,31.046 31.046,40 20,40C8.954,40 0,31.046 0,20Z"
android:fillColor="#EBEBE6"/>
<path
android:pathData="M24.167,26.667H24C22.6,26.667 21.9,26.667 21.365,26.394C20.895,26.154 20.512,25.772 20.272,25.302C20,24.767 20,24.067 20,22.667V17.333C20,15.933 20,15.233 20.272,14.698C20.512,14.228 20.895,13.845 21.365,13.606C21.9,13.333 22.6,13.333 24,13.333H24.167M24.167,26.667C24.167,27.587 24.913,28.333 25.833,28.333C26.754,28.333 27.5,27.587 27.5,26.667C27.5,25.746 26.754,25 25.833,25C24.913,25 24.167,25.746 24.167,26.667ZM24.167,13.333C24.167,14.254 24.913,15 25.833,15C26.754,15 27.5,14.254 27.5,13.333C27.5,12.413 26.754,11.667 25.833,11.667C24.913,11.667 24.167,12.413 24.167,13.333ZM15.833,20L24.167,20M15.833,20C15.833,20.92 15.087,21.667 14.167,21.667C13.246,21.667 12.5,20.92 12.5,20C12.5,19.079 13.246,18.333 14.167,18.333C15.087,18.333 15.833,19.079 15.833,20ZM24.167,20C24.167,20.92 24.913,21.667 25.833,21.667C26.754,21.667 27.5,20.92 27.5,20C27.5,19.079 26.754,18.333 25.833,18.333C24.913,18.333 24.167,19.079 24.167,20Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#231F20"
android:strokeLineCap="round"/>
</vector>

View File

@ -1,6 +1,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="settings_title">Settings</string>
<string name="settings_advanced_settings">Advanced Settings</string>
<string name="settings_integrations">Integrations</string>
<string name="settings_about_us">About Us</string>
<string name="settings_feedback">Send Us Feedback</string>
<string name="settings_version">Version <xliff:g example="1" id="version">%s</xliff:g></string>