Message visibility based on foreground/background

This commit is contained in:
Milan Cerovsky 2025-04-11 14:10:43 +02:00
parent c45d672995
commit 1315ba04ec
16 changed files with 468 additions and 301 deletions

View File

@ -19,6 +19,7 @@ import co.electriccoin.zcash.spackle.StrictModeCompat
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.repository.FlexaRepository import co.electriccoin.zcash.ui.common.repository.FlexaRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepository
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -31,6 +32,7 @@ class ZcashApplication : CoroutineApplication() {
private val flexaRepository by inject<FlexaRepository>() private val flexaRepository by inject<FlexaRepository>()
private val applicationStateProvider: ApplicationStateProvider by inject() private val applicationStateProvider: ApplicationStateProvider by inject()
private val getAvailableCrashReporters: CrashReportersProvider by inject() private val getAvailableCrashReporters: CrashReportersProvider by inject()
private val homeMessageCacheRepository: HomeMessageCacheRepository by inject()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -68,6 +70,7 @@ class ZcashApplication : CoroutineApplication() {
configureAnalytics() configureAnalytics()
flexaRepository.init() flexaRepository.init()
homeMessageCacheRepository.init()
} }
private fun configureLogging() { private fun configureLogging() {

View File

@ -8,6 +8,8 @@ import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepositoryImpl import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.FlexaRepository import co.electriccoin.zcash.ui.common.repository.FlexaRepository
import co.electriccoin.zcash.ui.common.repository.FlexaRepositoryImpl import co.electriccoin.zcash.ui.common.repository.FlexaRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepository
import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepositoryImpl import co.electriccoin.zcash.ui.common.repository.KeystoneProposalRepositoryImpl
import co.electriccoin.zcash.ui.common.repository.ShieldFundsRepository import co.electriccoin.zcash.ui.common.repository.ShieldFundsRepository
@ -36,4 +38,5 @@ val repositoryModule =
singleOf(::TransactionFilterRepositoryImpl) bind TransactionFilterRepository::class singleOf(::TransactionFilterRepositoryImpl) bind TransactionFilterRepository::class
singleOf(::ZashiProposalRepositoryImpl) bind ZashiProposalRepository::class singleOf(::ZashiProposalRepositoryImpl) bind ZashiProposalRepository::class
singleOf(::ShieldFundsRepositoryImpl) bind ShieldFundsRepository::class singleOf(::ShieldFundsRepositoryImpl) bind ShieldFundsRepository::class
singleOf(::HomeMessageCacheRepositoryImpl) bind HomeMessageCacheRepository::class
} }

View File

@ -185,7 +185,7 @@ val useCaseModule =
factoryOf(::GetKeystoneStatusUseCase) factoryOf(::GetKeystoneStatusUseCase)
factoryOf(::GetCoinbaseStatusUseCase) factoryOf(::GetCoinbaseStatusUseCase)
factoryOf(::GetFlexaStatusUseCase) factoryOf(::GetFlexaStatusUseCase)
factoryOf(::GetHomeMessageUseCase) singleOf(::GetHomeMessageUseCase)
factoryOf(::OnUserSavedWalletBackupUseCase) factoryOf(::OnUserSavedWalletBackupUseCase)
factoryOf(::RemindWalletBackupLaterUseCase) factoryOf(::RemindWalletBackupLaterUseCase)
factoryOf(::RemindShieldFundsLaterUseCase) factoryOf(::RemindShieldFundsLaterUseCase)

View File

@ -34,6 +34,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW import co.electriccoin.zcash.ui.NavigationTargets.WHATS_NEW
import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import co.electriccoin.zcash.ui.common.provider.isInForeground import co.electriccoin.zcash.ui.common.provider.isInForeground
import co.electriccoin.zcash.ui.design.LocalKeyboardManager import co.electriccoin.zcash.ui.design.LocalKeyboardManager
@ -141,6 +142,7 @@ internal fun MainActivity.Navigation() {
val flexaViewModel = koinViewModel<FlexaViewModel>() val flexaViewModel = koinViewModel<FlexaViewModel>()
val navigationRouter = koinInject<NavigationRouter>() val navigationRouter = koinInject<NavigationRouter>()
val sheetStateManager = LocalSheetStateManager.current val sheetStateManager = LocalSheetStateManager.current
val messageAvailabilityDataSource = koinInject<MessageAvailabilityDataSource>()
// Helper properties for triggering the system security UI from callbacks // Helper properties for triggering the system security UI from callbacks
val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) = val (exportPrivateDataAuthentication, setExportPrivateDataAuthentication) =
@ -153,14 +155,16 @@ internal fun MainActivity.Navigation() {
navController, navController,
flexaViewModel, flexaViewModel,
keyboardManager, keyboardManager,
sheetStateManager sheetStateManager,
messageAvailabilityDataSource
) { ) {
NavigatorImpl( NavigatorImpl(
activity = this@Navigation, activity = this@Navigation,
navController = navController, navController = navController,
flexaViewModel = flexaViewModel, flexaViewModel = flexaViewModel,
keyboardManager = keyboardManager, keyboardManager = keyboardManager,
sheetStateManager = sheetStateManager sheetStateManager = sheetStateManager,
messageAvailabilityDataSource = messageAvailabilityDataSource
) )
} }

View File

@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.serialization.generateHashCode import androidx.navigation.serialization.generateHashCode
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.design.KeyboardManager import co.electriccoin.zcash.ui.design.KeyboardManager
import co.electriccoin.zcash.ui.design.SheetStateManager import co.electriccoin.zcash.ui.design.SheetStateManager
import co.electriccoin.zcash.ui.screen.ExternalUrl import co.electriccoin.zcash.ui.screen.ExternalUrl
@ -25,6 +26,7 @@ class NavigatorImpl(
private val flexaViewModel: FlexaViewModel, private val flexaViewModel: FlexaViewModel,
private val keyboardManager: KeyboardManager, private val keyboardManager: KeyboardManager,
private val sheetStateManager: SheetStateManager, private val sheetStateManager: SheetStateManager,
private val messageAvailabilityDataSource: MessageAvailabilityDataSource,
) : Navigator { ) : Navigator {
override suspend fun executeCommand(command: NavigationCommand) { override suspend fun executeCommand(command: NavigationCommand) {
keyboardManager.close() keyboardManager.close()
@ -85,6 +87,7 @@ class NavigatorImpl(
throw UnsupportedOperationException("External url can be opened as last screen only") throw UnsupportedOperationException("External url can be opened as last screen only")
} }
messageAvailabilityDataSource.onThirdPartyUiShown()
WebBrowserUtil.startActivity(activity, route.url) WebBrowserUtil.startActivity(activity, route.url)
} }
@ -125,6 +128,7 @@ class NavigatorImpl(
throw UnsupportedOperationException("External url can be opened as last screen only") throw UnsupportedOperationException("External url can be opened as last screen only")
} }
messageAvailabilityDataSource.onThirdPartyUiShown()
WebBrowserUtil.startActivity(activity, route.url) WebBrowserUtil.startActivity(activity, route.url)
} }
@ -149,6 +153,10 @@ class NavigatorImpl(
else -> navController.executeNavigation(route = route) else -> navController.executeNavigation(route = route)
} }
} }
if (command.routes.lastOrNull() in listOf(ExternalUrl, co.electriccoin.zcash.ui.screen.flexa.Flexa) ) {
messageAvailabilityDataSource.onThirdPartyUiShown()
}
} }
private fun NavHostController.executeNavigation( private fun NavHostController.executeNavigation(
@ -171,11 +179,11 @@ class NavigatorImpl(
} }
private fun createFlexaFlow(flexaViewModel: FlexaViewModel) { private fun createFlexaFlow(flexaViewModel: FlexaViewModel) {
messageAvailabilityDataSource.onThirdPartyUiShown()
Flexa Flexa
.buildSpend() .buildSpend()
.onTransactionRequest { result -> .onTransactionRequest { result -> flexaViewModel.createTransaction(result) }
flexaViewModel.createTransaction(result) .build()
}.build()
.open(activity) .open(activity)
} }
} }

View File

@ -5,42 +5,75 @@ import co.electriccoin.zcash.ui.common.provider.ApplicationStateProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
interface MessageAvailabilityDataSource { interface MessageAvailabilityDataSource {
val canShowMessage: Boolean val canShowMessage: Boolean
fun observe(): StateFlow<Boolean> fun observe(): Flow<Boolean>
fun onMessageShown() fun onMessageShown()
fun onThirdPartyUiShown()
} }
class MessageAvailabilityDataSourceImpl( class MessageAvailabilityDataSourceImpl(
private val applicationStateProvider: ApplicationStateProvider applicationStateProvider: ApplicationStateProvider
): MessageAvailabilityDataSource { ) : MessageAvailabilityDataSource {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val state = MutableStateFlow(true) private val state = MutableStateFlow(
MessageAvailabilityData(
isAppInForeground = true,
isThirdPartyUiShown = false,
hasMessageBeenShown = false
)
)
override val canShowMessage: Boolean override val canShowMessage: Boolean
get() = state.value get() = state.value.canShowMessage
init { init {
scope.launch { applicationStateProvider.state
applicationStateProvider.state.collect { .onEach { event ->
if (it == Lifecycle.Event.ON_START) { if (event == Lifecycle.Event.ON_START) {
state.update { true } state.update {
it.copy(
isAppInForeground = true,
hasMessageBeenShown = if (it.isThirdPartyUiShown) it.hasMessageBeenShown else false,
isThirdPartyUiShown = false
)
}
} else if (event == Lifecycle.Event.ON_STOP) {
state.update {
it.copy(
isAppInForeground = it.isThirdPartyUiShown,
)
}
} }
} }
} .launchIn(scope)
} }
override fun observe(): StateFlow<Boolean> = state.asStateFlow() override fun observe(): Flow<Boolean> = state.map { it.canShowMessage }.distinctUntilChanged()
override fun onMessageShown() { override fun onMessageShown() {
state.update { false } state.update { it.copy(hasMessageBeenShown = true) }
}
override fun onThirdPartyUiShown() {
state.update { it.copy(isThirdPartyUiShown = true) }
} }
} }
private data class MessageAvailabilityData(
val isAppInForeground: Boolean,
val isThirdPartyUiShown: Boolean,
val hasMessageBeenShown: Boolean
) {
val canShowMessage = isAppInForeground && !hasMessageBeenShown
}

View File

@ -10,12 +10,7 @@ import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
// TODO [#292]: Should be moved to SDK-EXT-UI module. // TODO [#292]: Should be moved to SDK-EXT-UI module.
// TODO [#292]: https://github.com/Electric-Coin-Company/zashi-android/issues/292 // TODO [#292]: https://github.com/Electric-Coin-Company/zashi-android/issues/292
data class WalletSnapshot( data class WalletSnapshot(
val isZashi: Boolean,
val status: Synchronizer.Status, val status: Synchronizer.Status,
val processorInfo: CompactBlockProcessor.ProcessorInfo,
val orchardBalance: WalletBalance,
val saplingBalance: WalletBalance?,
val transparentBalance: Zatoshi,
val progress: PercentDecimal, val progress: PercentDecimal,
val synchronizerError: SynchronizerError? val synchronizerError: SynchronizerError?
) )

View File

@ -0,0 +1,88 @@
package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
interface HomeMessageCacheRepository {
/**
* Last message that was shown. Null if no message has been shown yet.
*/
var lastShownMessage: HomeMessageData?
/**
* Last message that was shown. Null if no message has been shown yet or if last message was null.
*/
var lastMessage: HomeMessageData?
fun init()
fun reset()
}
class HomeMessageCacheRepositoryImpl(
private val messageAvailabilityDataSource: MessageAvailabilityDataSource
) : HomeMessageCacheRepository {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override var lastShownMessage: HomeMessageData? = null
override var lastMessage: HomeMessageData? = null
override fun init() {
messageAvailabilityDataSource
.observe()
.onEach { canShowMessage ->
if (canShowMessage) {
lastShownMessage = null
lastMessage = null
}
}
.launchIn(scope)
}
override fun reset() {
lastShownMessage = null
lastMessage = null
}
}
sealed interface HomeMessageData {
val priority: Int
data class Error(val synchronizerError: SynchronizerError) : RuntimeMessage()
data object Disconnected : RuntimeMessage()
data class Restoring(val progress: Float) : RuntimeMessage()
data class Syncing(val progress: Float) : RuntimeMessage()
data object Updating : RuntimeMessage()
data object Backup : Prioritized {
override val priority: Int = 3
}
data class ShieldFunds(val zatoshi: Zatoshi) : Prioritized {
override val priority: Int = 2
}
data object EnableCurrencyConversion : Prioritized {
override val priority: Int = 1
}
}
/**
* Message which always is shown.
*/
sealed class RuntimeMessage : HomeMessageData {
override val priority: Int = 4
}
/**
* Message which always is displayed only if previous message was lower priority.
*/
sealed interface Prioritized : HomeMessageData

View File

@ -1,5 +1,7 @@
package co.electriccoin.zcash.ui.common.repository package co.electriccoin.zcash.ui.common.repository
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.AccountUuid
import cash.z.ecc.android.sdk.model.TransactionId import cash.z.ecc.android.sdk.model.TransactionId
import cash.z.ecc.android.sdk.model.TransactionOutput import cash.z.ecc.android.sdk.model.TransactionOutput
import cash.z.ecc.android.sdk.model.TransactionOverview import cash.z.ecc.android.sdk.model.TransactionOverview
@ -8,6 +10,7 @@ import cash.z.ecc.android.sdk.model.TransactionState.Expired
import cash.z.ecc.android.sdk.model.TransactionState.Pending import cash.z.ecc.android.sdk.model.TransactionState.Pending
import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.Zatoshi
import co.electriccoin.zcash.ui.common.datasource.AccountDataSource import co.electriccoin.zcash.ui.common.datasource.AccountDataSource
import co.electriccoin.zcash.ui.common.model.ZashiAccount
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -20,6 +23,7 @@ import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -38,6 +42,8 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
interface TransactionRepository { interface TransactionRepository {
val zashiTransactions: Flow<List<Transaction>?>
val currentTransactions: Flow<List<Transaction>?> val currentTransactions: Flow<List<Transaction>?>
suspend fun getMemos(transaction: Transaction): List<String> suspend fun getMemos(transaction: Transaction): List<String>
@ -52,21 +58,49 @@ interface TransactionRepository {
} }
class TransactionRepositoryImpl( class TransactionRepositoryImpl(
accountDataSource: AccountDataSource, private val accountDataSource: AccountDataSource,
private val synchronizerProvider: SynchronizerProvider, private val synchronizerProvider: SynchronizerProvider,
) : TransactionRepository { ) : TransactionRepository {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override val zashiTransactions: Flow<List<Transaction>?> =
observeTransactions(
accountFlow = accountDataSource.zashiAccount.map { it?.sdkAccount?.accountUuid }.distinctUntilChanged()
).stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, Duration.ZERO),
initialValue = null
)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override val currentTransactions: Flow<List<Transaction>?> = override val currentTransactions: Flow<List<Transaction>?> = accountDataSource.selectedAccount
.distinctUntilChangedBy { it?.sdkAccount?.accountUuid }
.flatMapLatest { selected ->
if (selected is ZashiAccount) {
zashiTransactions
} else {
observeTransactions(
accountFlow = accountDataSource.selectedAccount.map { it?.sdkAccount?.accountUuid }
.distinctUntilChanged()
)
}
}
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, Duration.ZERO),
initialValue = null
)
@OptIn(ExperimentalCoroutinesApi::class)
private fun TransactionRepositoryImpl.observeTransactions(accountFlow: Flow<AccountUuid?>) =
combine( combine(
synchronizerProvider.synchronizer, synchronizerProvider.synchronizer,
accountDataSource.selectedAccount.map { it?.sdkAccount } accountFlow
) { synchronizer, account -> ) { synchronizer, account ->
synchronizer to account synchronizer to account
}.distinctUntilChanged() }.distinctUntilChanged()
.flatMapLatest { (synchronizer, account) -> .flatMapLatest { (synchronizer, accountUuid) ->
if (synchronizer == null || account == null) { if (synchronizer == null || accountUuid == null) {
flowOf(null) flowOf(null)
} else { } else {
channelFlow<List<Transaction>?> { channelFlow<List<Transaction>?> {
@ -74,144 +108,9 @@ class TransactionRepositoryImpl(
launch { launch {
synchronizer synchronizer
.getTransactions(account.accountUuid) .getTransactions(accountUuid)
.mapLatest { transactions -> .mapLatest { transactions ->
transactions createTransactions(transactions = transactions, synchronizer = synchronizer)
.map { transaction ->
when (transaction.transactionState) {
Expired ->
when {
transaction.isShielding ->
ShieldTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Confirmed ->
when {
transaction.isShielding ->
ShieldTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Pending ->
when {
transaction.isShielding ->
ShieldTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
else -> error("Unexpected transaction stat")
}
}.sortedByDescending { transaction ->
transaction.timestamp ?: Instant.now()
}
}.collect { }.collect {
send(it) send(it)
} }
@ -222,11 +121,147 @@ class TransactionRepositoryImpl(
} }
} }
} }
}.stateIn( }
scope = scope,
started = SharingStarted.WhileSubscribed(5.seconds, Duration.ZERO), private suspend fun createTransactions(
initialValue = null transactions: List<TransactionOverview>,
) synchronizer: Synchronizer
) = transactions
.map { transaction ->
when (transaction.transactionState) {
Expired ->
when {
transaction.isShielding ->
ShieldTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Failed(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Confirmed ->
when {
transaction.isShielding ->
ShieldTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Success(
timestamp =
createTimestamp(transaction) ?: Instant.now(),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
Pending ->
when {
transaction.isShielding ->
ShieldTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.totalSpent,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.netValue,
overview = transaction
)
transaction.isSentTransaction ->
SendTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
fee = transaction.feePaid,
overview = transaction
)
else ->
ReceiveTransaction.Pending(
timestamp = createTimestamp(transaction),
transactionOutputs =
synchronizer.getTransactionOutputs
(transaction),
amount = transaction.netValue,
id = transaction.txId,
memoCount = transaction.memoCount,
overview = transaction
)
}
else -> error("Unexpected transaction stat")
}
}.sortedByDescending { transaction ->
transaction.timestamp ?: Instant.now()
}
private fun createTimestamp(transaction: TransactionOverview): Instant? = private fun createTimestamp(transaction: TransactionOverview): Instant? =
transaction.blockTimeEpochSeconds?.let { transaction.blockTimeEpochSeconds?.let {

View File

@ -4,7 +4,6 @@ import android.app.Application
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.WalletInitMode import cash.z.ecc.android.sdk.WalletInitMode
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor
import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.BlockHeight
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.PercentDecimal
@ -23,7 +22,6 @@ import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletAccount import co.electriccoin.zcash.ui.common.model.WalletAccount
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.ZashiAccount
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider
import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider import co.electriccoin.zcash.ui.common.provider.PersistableWalletProvider
import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider import co.electriccoin.zcash.ui.common.provider.SynchronizerProvider
@ -143,6 +141,7 @@ class WalletRepositoryImpl(
OnboardingState.NEEDS_WARN, OnboardingState.NEEDS_WARN,
OnboardingState.NEEDS_BACKUP, OnboardingState.NEEDS_BACKUP,
OnboardingState.NONE -> SecretState.NONE OnboardingState.NONE -> SecretState.NONE
OnboardingState.READY -> SecretState.READY OnboardingState.READY -> SecretState.READY
} }
} }
@ -190,13 +189,11 @@ class WalletRepositoryImpl(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override val currentWalletSnapshot: StateFlow<WalletSnapshot?> = override val currentWalletSnapshot: StateFlow<WalletSnapshot?> =
combine(synchronizer, currentAccount) { synchronizer, currentAccount -> synchronizer.flatMapLatest { synchronizer ->
synchronizer to currentAccount if (synchronizer == null) {
}.flatMapLatest { (synchronizer, currentAccount) ->
if (synchronizer == null || currentAccount == null) {
flowOf(null) flowOf(null)
} else { } else {
toWalletSnapshot(synchronizer, currentAccount) toWalletSnapshot(synchronizer)
} }
}.stateIn( }.stateIn(
scope = scope, scope = scope,
@ -355,29 +352,14 @@ private fun Synchronizer.toCommonError(): Flow<SynchronizerError?> =
// No good way around needing magic numbers for the indices // No good way around needing magic numbers for the indices
@Suppress("MagicNumber") @Suppress("MagicNumber")
private fun toWalletSnapshot( private fun toWalletSnapshot(synchronizer: Synchronizer) = combine(
synchronizer: Synchronizer, synchronizer.status, // 0
account: WalletAccount synchronizer.progress, // 1
) = combine( synchronizer.toCommonError() // 2
// 0
synchronizer.status,
// 1
synchronizer.processorInfo,
// 2
synchronizer.progress,
// 3
synchronizer.toCommonError()
) { flows -> ) { flows ->
val progressPercentDecimal = (flows[2] as PercentDecimal)
WalletSnapshot( WalletSnapshot(
isZashi = account is ZashiAccount,
status = flows[0] as Synchronizer.Status, status = flows[0] as Synchronizer.Status,
processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo, progress = flows[1] as PercentDecimal,
orchardBalance = account.unified.balance, synchronizerError = flows[2] as SynchronizerError?
saplingBalance = account.sapling?.balance,
transparentBalance = account.transparent.balance,
progress = progressPercentDecimal,
synchronizerError = flows[3] as SynchronizerError?
) )
} }

View File

@ -1,15 +1,22 @@
package co.electriccoin.zcash.ui.common.usecase package co.electriccoin.zcash.ui.common.usecase
import android.util.Log
import cash.z.ecc.android.sdk.Synchronizer import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.Zatoshi import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.datasource.WalletBackupAvailability import co.electriccoin.zcash.ui.common.datasource.WalletBackupAvailability
import co.electriccoin.zcash.ui.common.datasource.WalletBackupDataSource import co.electriccoin.zcash.ui.common.datasource.WalletBackupDataSource
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.repository.ExchangeRateRepository import co.electriccoin.zcash.ui.common.repository.ExchangeRateRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageData
import co.electriccoin.zcash.ui.common.repository.ReceiveTransaction
import co.electriccoin.zcash.ui.common.repository.RuntimeMessage
import co.electriccoin.zcash.ui.common.repository.ShieldFundsData import co.electriccoin.zcash.ui.common.repository.ShieldFundsData
import co.electriccoin.zcash.ui.common.repository.ShieldFundsRepository import co.electriccoin.zcash.ui.common.repository.ShieldFundsRepository
import co.electriccoin.zcash.ui.common.repository.TransactionRepository
import co.electriccoin.zcash.ui.common.repository.WalletRepository import co.electriccoin.zcash.ui.common.repository.WalletRepository
import co.electriccoin.zcash.ui.common.viewmodel.SynchronizerError
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -18,69 +25,117 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class GetHomeMessageUseCase( class GetHomeMessageUseCase(
private val walletRepository: WalletRepository, walletRepository: WalletRepository,
private val walletBackupDataSource: WalletBackupDataSource, walletBackupDataSource: WalletBackupDataSource,
private val exchangeRateRepository: ExchangeRateRepository, exchangeRateRepository: ExchangeRateRepository,
private val shieldFundsRepository: ShieldFundsRepository, shieldFundsRepository: ShieldFundsRepository,
transactionRepository: TransactionRepository,
private val messageAvailabilityDataSource: MessageAvailabilityDataSource,
private val cache: HomeMessageCacheRepository,
) { ) {
private val backupFlow = combine(
transactionRepository.zashiTransactions,
walletBackupDataSource.observe()
) { transactions, backup ->
if (backup is WalletBackupAvailability.Available && transactions.orEmpty().any { it is ReceiveTransaction }) {
backup
} else {
WalletBackupAvailability.Unavailable
}
}.distinctUntilChanged()
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
fun observe(): Flow<HomeMessageData?> = combine( private val flow = combine(
walletRepository.currentWalletSnapshot.filterNotNull(), walletRepository.currentWalletSnapshot.filterNotNull(),
walletRepository.walletRestoringState, walletRepository.walletRestoringState.filterNotNull(),
walletBackupDataSource.observe(), backupFlow,
exchangeRateRepository.state.map { it == ExchangeRateState.OptIn }.distinctUntilChanged(), exchangeRateRepository.state.map { it == ExchangeRateState.OptIn }.distinctUntilChanged(),
shieldFundsRepository.availability shieldFundsRepository.availability
) { walletSnapshot, walletStateInformation, backup, isCCAvailable, shieldFunds -> ) { walletSnapshot, walletStateInformation, backup, isCCAvailable, shieldFunds ->
when { createMessage(walletSnapshot, walletStateInformation, backup, shieldFunds, isCCAvailable)
walletSnapshot.synchronizerError != null -> { }.debounce(.5.seconds)
HomeMessageData.Error(walletSnapshot.synchronizerError) .map { message -> prioritizeMessage(message) }
}
walletSnapshot.status == Synchronizer.Status.DISCONNECTED -> { fun observe(): Flow<HomeMessageData?> = flow
HomeMessageData.Disconnected
}
walletSnapshot.status in listOf( private fun createMessage(
Synchronizer.Status.INITIALIZING, walletSnapshot: WalletSnapshot,
Synchronizer.Status.SYNCING, walletStateInformation: WalletRestoringState,
Synchronizer.Status.STOPPED backup: WalletBackupAvailability,
) -> { shieldFunds: ShieldFundsData,
val progress = walletSnapshot.progress.decimal * 100f isCCAvailable: Boolean
val result = when { ) = when {
walletStateInformation == WalletRestoringState.RESTORING -> { walletSnapshot.synchronizerError != null -> HomeMessageData.Error(walletSnapshot.synchronizerError)
HomeMessageData.Restoring(
progress = progress,
)
}
else -> { walletSnapshot.status == Synchronizer.Status.DISCONNECTED -> HomeMessageData.Disconnected
HomeMessageData.Syncing(progress = progress)
} walletSnapshot.status in listOf(
Synchronizer.Status.INITIALIZING,
Synchronizer.Status.SYNCING,
Synchronizer.Status.STOPPED
) -> {
val progress = walletSnapshot.progress.decimal * 100f
val result = when {
walletStateInformation == WalletRestoringState.RESTORING -> {
HomeMessageData.Restoring(
progress = progress,
)
}
else -> {
HomeMessageData.Syncing(progress = progress)
} }
result
} }
result
}
shieldFunds is ShieldFundsData.Available -> HomeMessageData.ShieldFunds(shieldFunds.amount) backup is WalletBackupAvailability.Available -> HomeMessageData.Backup
backup is WalletBackupAvailability.Available -> HomeMessageData.Backup shieldFunds is ShieldFundsData.Available -> HomeMessageData.ShieldFunds(shieldFunds.amount)
isCCAvailable -> HomeMessageData.EnableCurrencyConversion isCCAvailable -> HomeMessageData.EnableCurrencyConversion
else -> null
}
private fun prioritizeMessage(message: HomeMessageData?): HomeMessageData? {
val isSameMessageUpdate = message?.priority == cache.lastMessage?.priority // same but updated
val someMessageBeenShown = cache.lastShownMessage != null // has any message been shown while app in fg
val hasNoMessageBeenShownLately = cache.lastMessage == null // has no message been shown
val isHigherPriorityMessage = (message?.priority ?: 0) > (cache.lastShownMessage?.priority ?: 0)
val result = when {
message == null -> null
message is RuntimeMessage -> message
isSameMessageUpdate -> message
isHigherPriorityMessage -> if (hasNoMessageBeenShownLately) {
if (someMessageBeenShown) null else message
} else {
message
}
else -> null else -> null
} }
}.debounce(.5.seconds)
}
sealed interface HomeMessageData { if (result != null) {
data object EnableCurrencyConversion : HomeMessageData messageAvailabilityDataSource.onMessageShown()
data class ShieldFunds(val zatoshi: Zatoshi) : HomeMessageData cache.lastShownMessage = result
data object Backup : HomeMessageData }
data object Disconnected : HomeMessageData cache.lastMessage = result
data class Error(val synchronizerError: SynchronizerError) : HomeMessageData
data class Restoring(val progress: Float) : HomeMessageData Twig.debug {
data class Syncing(val progress: Float) : HomeMessageData when {
data object Updating : HomeMessageData message == null -> "Home message: no message to show"
result == null -> "Home message: ${message::class.simpleName} was filtered out"
else -> {
"Home message: ${result::class.simpleName} shown"
}
}
}
return result
}
} }

View File

@ -1,11 +1,14 @@
package co.electriccoin.zcash.ui.common.usecase package co.electriccoin.zcash.ui.common.usecase
import co.electriccoin.zcash.ui.common.repository.AddressBookRepository import co.electriccoin.zcash.ui.common.repository.AddressBookRepository
import co.electriccoin.zcash.ui.common.repository.HomeMessageCacheRepository
class ResetInMemoryDataUseCase( class ResetInMemoryDataUseCase(
private val addressBookRepository: AddressBookRepository, private val addressBookRepository: AddressBookRepository,
private val homeMessageCacheRepository: HomeMessageCacheRepository
) { ) {
suspend operator fun invoke() { suspend operator fun invoke() {
addressBookRepository.resetAddressBook() addressBookRepository.resetAddressBook()
homeMessageCacheRepository.reset()
} }
} }

View File

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

View File

@ -12,7 +12,7 @@ import co.electriccoin.zcash.ui.common.model.WalletAccount
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.usecase.GetHomeMessageUseCase import co.electriccoin.zcash.ui.common.usecase.GetHomeMessageUseCase
import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase import co.electriccoin.zcash.ui.common.usecase.GetSelectedWalletAccountUseCase
import co.electriccoin.zcash.ui.common.usecase.HomeMessageData import co.electriccoin.zcash.ui.common.repository.HomeMessageData
import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase import co.electriccoin.zcash.ui.common.usecase.IsRestoreSuccessDialogVisibleUseCase
import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase import co.electriccoin.zcash.ui.common.usecase.NavigateToCoinbaseUseCase
import co.electriccoin.zcash.ui.common.usecase.ShieldFundsUseCase import co.electriccoin.zcash.ui.common.usecase.ShieldFundsUseCase
@ -246,6 +246,6 @@ class HomeViewModel(
// ), // ),
// fullStackTrace = walletSnapshot.synchronizerError.getStackTrace(limit = null) // fullStackTrace = walletSnapshot.synchronizerError.getStackTrace(limit = null)
// ) // )
TODO() // TODO()
} }
} }

View File

@ -23,6 +23,7 @@ import co.electriccoin.zcash.ui.Navigator
import co.electriccoin.zcash.ui.NavigatorImpl import co.electriccoin.zcash.ui.NavigatorImpl
import co.electriccoin.zcash.ui.common.compose.LocalActivity import co.electriccoin.zcash.ui.common.compose.LocalActivity
import co.electriccoin.zcash.ui.common.compose.LocalNavController import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.datasource.MessageAvailabilityDataSource
import co.electriccoin.zcash.ui.common.model.OnboardingState import co.electriccoin.zcash.ui.common.model.OnboardingState
import co.electriccoin.zcash.ui.common.model.WalletRestoringState import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
@ -50,25 +51,29 @@ import org.koin.compose.koinInject
@Composable @Composable
fun MainActivity.OnboardingNavigation() { fun MainActivity.OnboardingNavigation() {
val activity = LocalActivity.current val activity = LocalActivity.current
val navigationRouter = koinInject<NavigationRouter>()
val navController = LocalNavController.current val navController = LocalNavController.current
val keyboardManager = LocalKeyboardManager.current val keyboardManager = LocalKeyboardManager.current
val flexaViewModel = koinViewModel<FlexaViewModel>()
val sheetStateManager = LocalSheetStateManager.current val sheetStateManager = LocalSheetStateManager.current
val navigationRouter = koinInject<NavigationRouter>()
val flexaViewModel = koinViewModel<FlexaViewModel>()
val messageAvailabilityDataSource = koinInject<MessageAvailabilityDataSource>()
val navigator: Navigator = val navigator: Navigator =
remember( remember(
navController, navController,
flexaViewModel, flexaViewModel,
keyboardManager, keyboardManager,
sheetStateManager sheetStateManager,
messageAvailabilityDataSource
) { ) {
NavigatorImpl( NavigatorImpl(
activity = this@OnboardingNavigation, activity = this@OnboardingNavigation,
navController = navController, navController = navController,
flexaViewModel = flexaViewModel, flexaViewModel = flexaViewModel,
keyboardManager = keyboardManager, keyboardManager = keyboardManager,
sheetStateManager = sheetStateManager sheetStateManager = sheetStateManager,
messageAvailabilityDataSource = messageAvailabilityDataSource
) )
} }

View File

@ -77,8 +77,7 @@ class TransactionHistoryWidgetViewModel(
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT), started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = initialValue = TransactionHistoryWidgetState.Loading
TransactionHistoryWidgetState.Loading
) )
private fun onTransactionClick(transaction: Transaction) { private fun onTransactionClick(transaction: Transaction) {