[#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 ### Added
- Address book local and remote storage support - Address book local and remote storage support
- New Integrations screen in settings
- New QR Code detail screen has been added - 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, - 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. 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 - Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
### Added ### Added
- New Integrations screen in settings
- Address book local and remote storage support - Address book local and remote storage support
- New QR Code detail screen has been added - 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. - 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.getValue
import co.electriccoin.zcash.ui.design.util.orDark import co.electriccoin.zcash.ui.design.util.orDark
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
fun ZashiSettingsListItem( fun ZashiSettingsListItem(
text: String, text: String,
@DrawableRes icon: Int, @DrawableRes icon: Int,
titleIcons: ImmutableList<Int> = persistentListOf(),
subtitle: String? = null, subtitle: String? = null,
isEnabled: Boolean = true, isEnabled: Boolean = true,
onClick: () -> Unit onClick: () -> Unit
@ -49,22 +52,20 @@ fun ZashiSettingsListItem(
text = stringRes(text), text = stringRes(text),
subtitle = subtitle?.let { stringRes(it) }, subtitle = subtitle?.let { stringRes(it) },
isEnabled = isEnabled, isEnabled = isEnabled,
onClick = onClick onClick = onClick,
icon = icon,
titleIcons = titleIcons
), ),
icon = icon,
) )
} }
@Composable @Composable
fun ZashiSettingsListItem( fun ZashiSettingsListItem(state: ZashiSettingsListItemState) {
state: ZashiSettingsListItemState,
@DrawableRes icon: Int
) {
ZashiSettingsListItem( ZashiSettingsListItem(
leading = { modifier -> leading = { modifier ->
ZashiSettingsListLeadingItem( ZashiSettingsListLeadingItem(
modifier = modifier, modifier = modifier,
icon = icon, icon = state.icon,
contentDescription = state.text.getValue() contentDescription = state.text.getValue()
) )
}, },
@ -72,7 +73,8 @@ fun ZashiSettingsListItem(
ZashiSettingsListContentItem( ZashiSettingsListContentItem(
modifier = modifier, modifier = modifier,
text = state.text.getValue(), text = state.text.getValue(),
subtitle = state.subtitle?.getValue() subtitle = state.subtitle?.getValue(),
titleIcons = state.titleIcons
) )
}, },
trailing = { modifier -> trailing = { modifier ->
@ -127,17 +129,28 @@ fun ZashiSettingsListTrailingItem(
fun ZashiSettingsListContentItem( fun ZashiSettingsListContentItem(
text: String, text: String,
subtitle: String?, subtitle: String?,
titleIcons: ImmutableList<Int>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
) { ) {
Text( Row {
text = text, Text(
style = ZashiTypography.textMd, text = text,
fontWeight = FontWeight.SemiBold, style = ZashiTypography.textMd,
color = ZashiColors.Text.textPrimary 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 { subtitle?.let {
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
@ -185,12 +198,13 @@ fun ZashiSettingsListItem(
data class ZashiSettingsListItemState( data class ZashiSettingsListItemState(
val text: StringResource, val text: StringResource,
@DrawableRes val icon: Int,
val subtitle: StringResource? = null, val subtitle: StringResource? = null,
val titleIcons: ImmutableList<Int> = persistentListOf(),
val isEnabled: Boolean = true, val isEnabled: Boolean = true,
val onClick: () -> Unit = {}, val onClick: () -> Unit = {},
) )
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun EnabledPreview() = private fun EnabledPreview() =
@ -200,12 +214,12 @@ private fun EnabledPreview() =
text = "Test", text = "Test",
subtitle = "Subtitle", subtitle = "Subtitle",
icon = R.drawable.ic_radio_button_checked, icon = R.drawable.ic_radio_button_checked,
onClick = {} onClick = {},
titleIcons = persistentListOf(R.drawable.ic_radio_button_checked)
) )
} }
} }
@Suppress("UnusedPrivateMember")
@PreviewScreens @PreviewScreens
@Composable @Composable
private fun DisabledPreview() = private fun DisabledPreview() =
@ -216,7 +230,7 @@ private fun DisabledPreview() =
subtitle = "Subtitle", subtitle = "Subtitle",
icon = R.drawable.ic_radio_button_checked, icon = R.drawable.ic_radio_button_checked,
isEnabled = false, isEnabled = false,
onClick = {} onClick = {},
) )
} }
} }

View File

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

View File

@ -1,7 +1,9 @@
package co.electriccoin.zcash.ui.screen.settings package co.electriccoin.zcash.ui.screen.settings
import androidx.compose.ui.test.junit4.ComposeContentTestRule 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.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.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.design.util.stringRes
import co.electriccoin.zcash.ui.screen.settings.model.SettingsState import co.electriccoin.zcash.ui.screen.settings.model.SettingsState
@ -120,7 +122,12 @@ class SettingsViewTestSetup(
}, },
onAddressBookClick = { onAddressBookClick = {
onAddressBookCount.incrementAndGet() onAddressBookCount.incrementAndGet()
} },
integrations =
ZashiSettingsListItemState(
stringRes("Integrations"),
R.drawable.ic_settings_integrations,
)
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, 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.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.AddressBookRepositoryImpl 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.ConfigurationRepository
import co.electriccoin.zcash.ui.common.repository.ConfigurationRepositoryImpl 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.WalletRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepositoryImpl import co.electriccoin.zcash.ui.common.repository.WalletRepositoryImpl
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
@ -14,5 +18,7 @@ val repositoryModule =
module { module {
singleOf(::WalletRepositoryImpl) bind WalletRepository::class singleOf(::WalletRepositoryImpl) bind WalletRepository::class
singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class singleOf(::ConfigurationRepositoryImpl) bind ConfigurationRepository::class
singleOf(::ExchangeRateRepositoryImpl) bind ExchangeRateRepository::class
singleOf(::BalanceRepositoryImpl) bind BalanceRepository::class
singleOf(::AddressBookRepositoryImpl) bind AddressBookRepository::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.GetContactByAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase import co.electriccoin.zcash.ui.common.usecase.GetPersistableWalletUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedEndpointUseCase 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.GetSynchronizerUseCase
import co.electriccoin.zcash.ui.common.usecase.GetTransparentAddressUseCase 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.ObserveAddressBookContactsUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveContactByAddressUseCase 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.ObserveFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedEndpointUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveSelectedEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.ObserveSynchronizerUseCase 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.PersistEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
@ -53,6 +56,9 @@ val useCaseModule =
singleOf(::ObserveContactPickedUseCase) singleOf(::ObserveContactPickedUseCase)
singleOf(::GetAddressesUseCase) singleOf(::GetAddressesUseCase)
singleOf(::CopyToClipboardUseCase) singleOf(::CopyToClipboardUseCase)
singleOf(::ObserveWalletStateUseCase)
singleOf(::IsCoinbaseAvailableUseCase)
singleOf(::GetSpendingKeyUseCase)
singleOf(::ShareImageUseCase) singleOf(::ShareImageUseCase)
singleOf(::Zip321BuildUriUseCase) 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.chooseserver.ChooseServerViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
@ -66,5 +67,6 @@ val viewModelModule =
viewModelOf(::UpdateContactViewModel) viewModelOf(::UpdateContactViewModel)
viewModelOf(::ReceiveViewModel) viewModelOf(::ReceiveViewModel)
viewModelOf(::QrCodeViewModel) viewModelOf(::QrCodeViewModel)
viewModelOf(::IntegrationsViewModel)
viewModelOf(::RequestViewModel) 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.EXCHANGE_RATE_OPT_IN
import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HOME import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
@ -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.exchangerate.settings.AndroidSettingsExchangeRateOptIn
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome 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.qrcode.WrapQrCode
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest import co.electriccoin.zcash.ui.screen.request.WrapRequest
@ -225,6 +227,9 @@ internal fun MainActivity.Navigation() {
composable(WHATS_NEW) { composable(WHATS_NEW) {
WrapWhatsNew() WrapWhatsNew()
} }
composable(INTEGRATIONS) {
WrapIntegrations()
}
composable(EXCHANGE_RATE_OPT_IN) { composable(EXCHANGE_RATE_OPT_IN) {
AndroidExchangeRateOptIn() AndroidExchangeRateOptIn()
} }
@ -539,6 +544,7 @@ object NavigationTargets {
const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in" const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in"
const val SUPPORT = "support" const val SUPPORT = "support"
const val WHATS_NEW = "whats_new" const val WHATS_NEW = "whats_new"
const val INTEGRATIONS = "integrations"
} }
object NavigationArgs { 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 package co.electriccoin.zcash.ui.common.repository
import android.app.Application 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.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator 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.FastestServersResult
import cash.z.ecc.android.sdk.model.PercentDecimal
import cash.z.ecc.android.sdk.model.PersistableWallet 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.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 cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.spackle.Twig 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.FastestServersState
import co.electriccoin.zcash.ui.common.model.OnboardingState 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.provider.GetDefaultServersProvider
import co.electriccoin.zcash.ui.common.viewmodel.SecretState 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.PersistableWalletPreferenceDefault
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -44,13 +62,31 @@ import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
interface WalletRepository { interface WalletRepository {
/**
* Synchronizer that is retained long enough to survive configuration changes.
*/
val synchronizer: StateFlow<Synchronizer?> val synchronizer: StateFlow<Synchronizer?>
val secretState: StateFlow<SecretState?> val secretState: StateFlow<SecretState>
val fastestServers: StateFlow<FastestServersState> val fastestServers: StateFlow<FastestServersState>
val persistableWallet: Flow<PersistableWallet?> val persistableWallet: Flow<PersistableWallet?>
val addresses: StateFlow<WalletAddresses?> 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) fun persistWallet(persistableWallet: PersistableWallet)
@ -82,7 +118,7 @@ class WalletRepositoryImpl(
/** /**
* A flow of the wallet onboarding state. * A flow of the wallet onboarding state.
*/ */
private val onboardingState = override val onboardingState =
flow { flow {
emitAll( emitAll(
StandardPreferenceKeys.ONBOARDING_STATE.observe(standardPreferenceProvider()).map { persistedNumber -> StandardPreferenceKeys.ONBOARDING_STATE.observe(standardPreferenceProvider()).map { persistedNumber ->
@ -122,6 +158,26 @@ class WalletRepositoryImpl(
initialValue = SecretState.Loading 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) @OptIn(ExperimentalCoroutinesApi::class)
override val fastestServers = override val fastestServers =
channelFlow { channelFlow {
@ -180,6 +236,63 @@ class WalletRepositoryImpl(
null 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. * 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() 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 android.content.Intent
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator import cash.z.ecc.android.sdk.WalletCoordinator
import cash.z.ecc.android.sdk.WalletInitMode 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.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.PersistableWallet
import cash.z.ecc.android.sdk.model.TransactionOverview import cash.z.ecc.android.sdk.model.TransactionOverview
import cash.z.ecc.android.sdk.model.TransactionRecipient import cash.z.ecc.android.sdk.model.TransactionRecipient
import cash.z.ecc.android.sdk.model.WalletAddresses 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.model.ZcashNetwork
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import cash.z.ecc.sdk.type.fromResources import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.StandardPreferenceProvider import co.electriccoin.zcash.preference.StandardPreferenceProvider
import co.electriccoin.zcash.preference.model.entry.NullableBooleanPreferenceDefault
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.MainActivity 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.OnboardingState
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.model.WalletSnapshot import co.electriccoin.zcash.ui.common.model.WalletSnapshot
import co.electriccoin.zcash.ui.common.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.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.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.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.account.ext.TransactionOverviewExt 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.ext.getSortHeight
import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState import co.electriccoin.zcash.ui.screen.account.state.TransactionHistorySyncState
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine 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.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest 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.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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 // To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences. // for loading the preferences.
@ -89,139 +55,30 @@ import kotlin.time.Duration.Companion.seconds
@Suppress("LongParameterList", "TooManyFunctions") @Suppress("LongParameterList", "TooManyFunctions")
class WalletViewModel( class WalletViewModel(
application: Application, application: Application,
observeSynchronizer: ObserveSynchronizerUseCase, balanceRepository: BalanceRepository,
private val walletCoordinator: WalletCoordinator, private val walletCoordinator: WalletCoordinator,
private val walletRepository: WalletRepository, private val walletRepository: WalletRepository,
private val exchangeRateRepository: ExchangeRateRepository,
private val encryptedPreferenceProvider: EncryptedPreferenceProvider, private val encryptedPreferenceProvider: EncryptedPreferenceProvider,
private val standardPreferenceProvider: StandardPreferenceProvider, private val standardPreferenceProvider: StandardPreferenceProvider,
private val getAvailableServers: GetDefaultServersProvider private val getAvailableServers: GetDefaultServersProvider,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
val navigationCommand = MutableSharedFlow<String>() val navigationCommand = exchangeRateRepository.navigationCommand
val backNavigationCommand = MutableSharedFlow<Unit>() val backNavigationCommand = exchangeRateRepository.backNavigationCommand
/** val synchronizer = walletRepository.synchronizer
* Synchronizer that is retained long enough to survive configuration changes.
*/
val synchronizer = observeSynchronizer()
/** val walletRestoringState = walletRepository.walletRestoringState
* 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 walletStateInformation = walletRepository.walletStateInformation
* 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 secretState: StateFlow<SecretState> = walletRepository.secretState
* 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
)
// This needs to be refactored once we support pin lock // This needs to be refactored once we support pin lock
val spendingKey = val spendingKey = walletRepository.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
)
@OptIn(ExperimentalCoroutinesApi::class) val walletSnapshot: StateFlow<WalletSnapshot?> = walletRepository.walletSnapshot
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 addresses: StateFlow<WalletAddresses?> = walletRepository.addresses val addresses: StateFlow<WalletAddresses?> = walletRepository.addresses
@ -277,179 +134,23 @@ class WalletViewModel(
initialValue = TransactionHistorySyncState.Loading initialValue = TransactionHistorySyncState.Loading
) )
val isExchangeRateUsdOptedIn = nullableBooleanStateFlow(StandardPreferenceKeys.EXCHANGE_RATE_OPTED_IN) val isExchangeRateUsdOptedIn = exchangeRateRepository.isExchangeRateUsdOptedIn
@OptIn(ExperimentalCoroutinesApi::class) val exchangeRateUsd = exchangeRateRepository.state
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)
)
private val usdExchangeRateTimestamp = val balanceState = balanceRepository.state
exchangeRateUsdInternal
.map {
it.currencyConversion?.timestamp
}
.distinctUntilChanged()
private var lastExchangeRateUsdValue: ExchangeRateState = ExchangeRateState.OptedOut fun refreshExchangeRateUsd() {
exchangeRateRepository.refreshExchangeRateUsd()
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)
} }
private fun showOptInExchangeRateUsd() = fun optInExchangeRateUsd(optIn: Boolean) {
viewModelScope.launch { exchangeRateRepository.optInExchangeRateUsd(optIn)
navigationCommand.emit(EXCHANGE_RATE_OPT_IN) }
}
fun dismissOptInExchangeRateUsd() {
exchangeRateRepository.dismissOptInExchangeRateUsd()
}
/** /**
* Creates a wallet asynchronously and then persists it. Clients observe * Creates a wallet asynchronously and then persists it. Clients observe
@ -574,24 +275,6 @@ class WalletViewModel(
// Nothing to close // 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.isSyncing() = this == Synchronizer.Status.SYNCING
fun Synchronizer.Status.isSynced() = this == Synchronizer.Status.SYNCED 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 package co.electriccoin.zcash.ui.screen.advancedsettings
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
data class AdvancedSettingsState( data class AdvancedSettingsState(
val onBack: () -> Unit, val onBack: () -> Unit,
val onRecoveryPhraseClick: () -> Unit, val onRecoveryPhraseClick: () -> Unit,
@ -9,5 +7,4 @@ data class AdvancedSettingsState(
val onChooseServerClick: () -> Unit, val onChooseServerClick: () -> Unit,
val onCurrencyConversionClick: () -> Unit, val onCurrencyConversionClick: () -> Unit,
val onDeleteZashiClick: () -> Unit, val onDeleteZashiClick: () -> Unit,
val coinbaseButton: ZashiSettingsListItemState?,
) )

View File

@ -2,14 +2,11 @@
package co.electriccoin.zcash.ui.screen.advancedsettings package co.electriccoin.zcash.ui.screen.advancedsettings
import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.advancedsettings.view.AdvancedSettings import co.electriccoin.zcash.ui.screen.advancedsettings.view.AdvancedSettings
@ -23,7 +20,6 @@ internal fun WrapAdvancedSettings(
goExportPrivateData: () -> Unit, goExportPrivateData: () -> Unit,
goSeedRecovery: () -> Unit, goSeedRecovery: () -> Unit,
) { ) {
val activity = LocalActivity.current
val navController = LocalNavController.current val navController = LocalNavController.current
val walletViewModel = koinActivityViewModel<WalletViewModel>() val walletViewModel = koinActivityViewModel<WalletViewModel>()
val viewModel = koinViewModel<AdvancedSettingsViewModel>() 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) { LaunchedEffect(Unit) {
viewModel.backNavigationCommand.collect { viewModel.backNavigationCommand.collect {
navController.popBackStack() 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.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem 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.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.orDark 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.AdvancedSettingsState
import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsTag import co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsTag
@ -70,13 +68,13 @@ fun AdvancedSettings(
) { ) {
ZashiSettingsListItem( ZashiSettingsListItem(
text = stringResource(id = R.string.advanced_settings_recovery), 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 onClick = state.onRecoveryPhraseClick
) )
ZashiHorizontalDivider() ZashiHorizontalDivider()
ZashiSettingsListItem( ZashiSettingsListItem(
text = stringResource(id = R.string.advanced_settings_export), 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 onClick = state.onExportPrivateDataClick
) )
ZashiHorizontalDivider() ZashiHorizontalDivider()
@ -84,7 +82,7 @@ fun AdvancedSettings(
text = stringResource(id = R.string.advanced_settings_choose_server), text = stringResource(id = R.string.advanced_settings_choose_server),
icon = icon =
R.drawable.ic_advanced_settings_choose_server orDark 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 onClick = state.onChooseServerClick
) )
ZashiHorizontalDivider() ZashiHorizontalDivider()
@ -92,16 +90,9 @@ fun AdvancedSettings(
text = stringResource(id = R.string.advanced_settings_currency_conversion), text = stringResource(id = R.string.advanced_settings_currency_conversion),
icon = icon =
R.drawable.ic_advanced_settings_currency_conversion orDark 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 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.height(24.dp))
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Row( Row(
@ -171,11 +162,6 @@ private fun AdvancedSettingsPreview() =
onChooseServerClick = {}, onChooseServerClick = {},
onCurrencyConversionClick = {}, onCurrencyConversionClick = {},
onDeleteZashiClick = {}, onDeleteZashiClick = {},
coinbaseButton =
ZashiSettingsListItemState(
text = stringRes("Coinbase"),
onClick = {}
)
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
) )

View File

@ -2,27 +2,14 @@ package co.electriccoin.zcash.ui.screen.advancedsettings.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import co.electriccoin.zcash.ui.BuildConfig
import co.electriccoin.zcash.ui.NavigationTargets 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 co.electriccoin.zcash.ui.screen.advancedsettings.AdvancedSettingsState
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AdvancedSettingsViewModel( class AdvancedSettingsViewModel : ViewModel() {
getVersionInfo: GetVersionInfoProvider,
getZcashCurrency: GetZcashCurrencyProvider,
private val getTransparentAddress: GetTransparentAddressUseCase,
) : ViewModel() {
private val forceShowCoinbaseForDebug = getVersionInfo().let { it.isDebuggable && !it.isRunningUnderTestService }
val state = val state =
MutableStateFlow( MutableStateFlow(
AdvancedSettingsState( AdvancedSettingsState(
@ -32,22 +19,11 @@ class AdvancedSettingsViewModel(
onChooseServerClick = ::onChooseServerClick, onChooseServerClick = ::onChooseServerClick,
onCurrencyConversionClick = ::onCurrencyConversionClick, onCurrencyConversionClick = ::onCurrencyConversionClick,
onDeleteZashiClick = {}, 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() ).asStateFlow()
val navigationCommand = MutableSharedFlow<String>() val navigationCommand = MutableSharedFlow<String>()
val backNavigationCommand = MutableSharedFlow<Unit>() val backNavigationCommand = MutableSharedFlow<Unit>()
val coinbaseNavigationCommand = MutableSharedFlow<String>()
private fun onChooseServerClick() = private fun onChooseServerClick() =
viewModelScope.launch { viewModelScope.launch {
@ -59,32 +35,6 @@ class AdvancedSettingsViewModel(
navigationCommand.emit(NavigationTargets.SETTINGS_EXCHANGE_RATE_OPT_IN) 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() = fun onBack() =
viewModelScope.launch { viewModelScope.launch {
backNavigationCommand.emit(Unit) 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 package co.electriccoin.zcash.ui.screen.settings.model
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItemState
import co.electriccoin.zcash.ui.design.util.StringResource import co.electriccoin.zcash.ui.design.util.StringResource
data class SettingsState( data class SettingsState(
@ -8,6 +9,7 @@ data class SettingsState(
val settingsTroubleshootingState: SettingsTroubleshootingState?, val settingsTroubleshootingState: SettingsTroubleshootingState?,
val onAddressBookClick: () -> Unit, val onAddressBookClick: () -> Unit,
val onBack: () -> Unit, val onBack: () -> Unit,
val integrations: ZashiSettingsListItemState,
val onAdvancedSettingsClick: () -> Unit, val onAdvancedSettingsClick: () -> Unit,
val onAboutUsClick: () -> Unit, val onAboutUsClick: () -> Unit,
val onSendUsFeedbackClick: () -> 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.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider import co.electriccoin.zcash.ui.design.component.ZashiHorizontalDivider
import co.electriccoin.zcash.ui.design.component.ZashiSettingsListItem 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.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.util.getValue 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.design.util.stringRes
import co.electriccoin.zcash.ui.screen.settings.SettingsTag 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.SettingsState
import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod") @Suppress("LongMethod")
@Composable @Composable
@ -82,21 +83,23 @@ fun Settings(
onClick = state.onAddressBookClick onClick = state.onAddressBookClick
) )
ZashiHorizontalDivider() ZashiHorizontalDivider()
ZashiSettingsListItem(state = state.integrations)
ZashiHorizontalDivider()
ZashiSettingsListItem( ZashiSettingsListItem(
text = stringResource(id = R.string.settings_advanced_settings), 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 onClick = state.onAdvancedSettingsClick
) )
ZashiHorizontalDivider() ZashiHorizontalDivider()
ZashiSettingsListItem( ZashiSettingsListItem(
text = stringResource(id = R.string.settings_about_us), 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 onClick = state.onAboutUsClick
) )
ZashiHorizontalDivider() ZashiHorizontalDivider()
ZashiSettingsListItem( ZashiSettingsListItem(
text = stringResource(id = R.string.settings_feedback), 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 onClick = state.onSendUsFeedbackClick
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
@ -104,7 +107,7 @@ fun Settings(
Image( Image(
modifier = Modifier.align(CenterHorizontally), modifier = Modifier.align(CenterHorizontally),
painter = painter =
painterResource(id = R.drawable.ic_settings_zashi orDark R.drawable.ic_settings_zashi_dark), painterResource(id = R.drawable.ic_settings_zashi),
contentDescription = "" contentDescription = ""
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -236,7 +239,13 @@ private fun PreviewSettings() {
onAdvancedSettingsClick = {}, onAdvancedSettingsClick = {},
onAboutUsClick = {}, onAboutUsClick = {},
onSendUsFeedbackClick = {}, onSendUsFeedbackClick = {},
onAddressBookClick = {} onAddressBookClick = {},
integrations =
ZashiSettingsListItemState(
icon = R.drawable.ic_settings_integrations,
text = stringRes("Integrations"),
titleIcons = persistentListOf(R.drawable.ic_integrations_coinbase)
) {}
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
) )
@ -258,7 +267,13 @@ private fun PreviewSettingsLoading() {
onAdvancedSettingsClick = {}, onAdvancedSettingsClick = {},
onAboutUsClick = {}, onAboutUsClick = {},
onSendUsFeedbackClick = {}, onSendUsFeedbackClick = {},
onAddressBookClick = {} onAddressBookClick = {},
integrations =
ZashiSettingsListItemState(
icon = R.drawable.ic_settings_integrations,
text = stringRes("Integrations"),
titleIcons = persistentListOf(R.drawable.ic_integrations_coinbase)
) {}
), ),
topAppBarSubTitleState = TopAppBarSubTitleState.None, 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.preference.model.entry.BooleanPreferenceDefault
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase import co.electriccoin.zcash.ui.common.usecase.ObserveConfigurationUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries 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.design.util.stringRes
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import co.electriccoin.zcash.ui.screen.addressbook.AddressBookArgs 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.SettingsState
import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState import co.electriccoin.zcash.ui.screen.settings.model.SettingsTroubleshootingState
import co.electriccoin.zcash.ui.screen.settings.model.TroubleshootingItemState import co.electriccoin.zcash.ui.screen.settings.model.TroubleshootingItemState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -99,6 +102,13 @@ class SettingsViewModel(
version = stringRes(R.string.settings_version, versionInfo.versionName), version = stringRes(R.string.settings_version, versionInfo.versionName),
settingsTroubleshootingState = troubleshootingState, settingsTroubleshootingState = troubleshootingState,
onBack = ::onBack, 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, onAdvancedSettingsClick = ::onAdvancedSettingsClick,
onAboutUsClick = ::onAboutUsClick, onAboutUsClick = ::onAboutUsClick,
onSendUsFeedbackClick = ::onSendUsFeedbackClick, onSendUsFeedbackClick = ::onSendUsFeedbackClick,
@ -140,6 +150,11 @@ class SettingsViewModel(
backNavigationCommand.emit(Unit) backNavigationCommand.emit(Unit)
} }
private fun onIntegrationsClick() =
viewModelScope.launch {
navigationCommand.emit(INTEGRATIONS)
}
private fun onAdvancedSettingsClick() = private fun onAdvancedSettingsClick() =
viewModelScope.launch { viewModelScope.launch {
navigationCommand.emit(ADVANCED_SETTINGS) navigationCommand.emit(ADVANCED_SETTINGS)

View File

@ -4,7 +4,6 @@
<string name="advanced_settings_export">Export Private Data</string> <string name="advanced_settings_export">Export Private Data</string>
<string name="advanced_settings_choose_server">Choose a Server</string> <string name="advanced_settings_choose_server">Choose a Server</string>
<string name="advanced_settings_currency_conversion">Currency Conversion</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_info">You will be asked to confirm on the next screen</string>
<string name="advanced_settings_delete_button">Delete Zashi</string> <string name="advanced_settings_delete_button">Delete Zashi</string>
</resources> </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"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_advanced_settings">Advanced 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_about_us">About Us</string>
<string name="settings_feedback">Send Us Feedback</string> <string name="settings_feedback">Send Us Feedback</string>
<string name="settings_version">Version <xliff:g example="1" id="version">%s</xliff:g></string> <string name="settings_version">Version <xliff:g example="1" id="version">%s</xliff:g></string>