From 694e993be605d0ea44a9a22f402722c3fa00016f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 10:15:13 -0400 Subject: [PATCH] fix(wallet): unify tab loading and stop the tutorial from showing for existing accounts Two defects, one root cause: the wallet tab reads a local cache that starts empty on every fresh login, and had no way to tell "this account has no activity" from "we haven't looked yet". - MessageDataSource.hasEverAddedMoney() and ChatMessageDataSource.hasEverTipped() read the per-user DB (and, for the latter, the account id) once at subscription time. The DB is created at login, after singletons build their flow graphs, so a subscriber that started first latched onto a constant `false` for the whole session -- pinning the onboarding checklist to "incomplete" and leaving the new-user tutorial on screen for an established account. Both now resolve through FlipcashDatabase.observeInstance(), matching what observeRecent() already did. - ActivityFeedCoordinator publishes a FeedSyncState so callers can distinguish an empty account from an unsynced cache. It resets to Unknown when API access is lost, so the next account waits for its own fetch. Unavailable exists so an offline device stops waiting instead of spinning forever; the 60s poller upgrades it. - WalletViewModel.State.onboardingItems is nullable ("unknown"), and isAwaitingActivity covers the window before the feed has been reconciled. Local rows short-circuit the wait. - WalletScreenContent gates the whole tab behind one CodeCircularProgressIndicator until tokens have hydrated and the activity side has settled. BalanceHeader's own spinner is consequently unreachable here; v1's BalanceScreen still uses it. v2-only: BalanceScreenContent never reads onboardingItems and BalanceHeader is untouched, so the v1 path is unaffected. --- .../balance/internal/WalletScreenContent.kt | 23 +++- .../app/balance/internal/WalletViewModel.kt | 38 ++++++- .../internal/WalletLoadingStateTest.kt | 103 ++++++++++++++++++ .../sources/ChatMessageDataSource.kt | 28 ++++- .../persistence/sources/MessageDataSource.kt | 16 ++- .../ActivityFeedCoordinator.kt | 61 ++++++++++- 6 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt index 9fb8ccc309..8ce42fbd78 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletScreenContent.kt @@ -47,6 +47,7 @@ import com.flipcash.app.tokens.ui.SelectTokenViewModel import com.flipcash.features.balance.R import com.flipcash.shared.transactionhistory.recentActivitySection import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeCircularProgressIndicator private const val TokenStackKey = "tokenStack" @@ -70,6 +71,24 @@ internal fun WalletScreenContent( tokenState: SelectTokenViewModel.State, dispatchEvent: (WalletViewModel.Event) -> Unit, ) { + // One loading state for the whole tab. The balance, the card deck, and the activity preview + // arrive from three independent sources; letting each stage itself meant the tab assembled in + // pieces -- a spinner inside the header while the body below it had already decided, from a + // still-empty cache, that this was a brand-new account and drawn the tutorial. Nothing renders + // until all three can be drawn together, and BalanceHeader's own spinner is consequently dead + // code on this screen (v1's BalanceScreen still uses it). + if (tokenState.tokens == null || balanceState.isAwaitingActivity) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = LocalTabBarPadding.current.calculateBottomPadding()), + contentAlignment = Alignment.Center, + ) { + CodeCircularProgressIndicator() + } + return + } + val listState = rememberLazyListState() // Px the token stack has scrolled above the viewport top, read live so the stack collapses (then // releases and scrolls off) as the list scrolls. A lambda so the stack reads it in its placement @@ -123,13 +142,13 @@ internal fun WalletScreenContent( Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) } - if (!balanceState.isNewUserTutorialComplete) { + balanceState.onboardingItems?.takeIf { !balanceState.isNewUserTutorialComplete }?.let { items -> item { NewUserTutorial( modifier = Modifier.fillMaxWidth() .padding(bottom = CodeTheme.dimens.grid.x5), title = stringResource(R.string.title_tipOnboarding), - items = balanceState.onboardingItems, + items = items, ) { item -> when (item) { is TutorialItem.AddMoney -> { diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index 5e6967094f..cea62b350f 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -6,6 +6,7 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.balance.internal.components.TutorialItem import com.flipcash.app.core.AppRoute import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator +import com.flipcash.shared.transactionhistory.FeedSyncState import com.flipcash.shared.transactionhistory.TransactionListItem import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.userflags.UserFlagsCoordinator @@ -41,25 +42,47 @@ internal class WalletViewModel @Inject constructor( ) { data class State( val preferredOnRampProvider: OnRampProvider.Defined? = null, - val onboardingItems: List = emptyList(), + /** + * Onboarding milestones, or `null` while they are still unknown. The distinction matters: + * an empty/incomplete checklist is what draws the new-user tutorial, and every milestone + * reads as incomplete before its source has reported. + */ + val onboardingItems: List? = null, /** * Preview of the most recent unified cross-token activity — at most [RECENT_PREVIEW_COUNT] * rows. The coordinator owns the mapping and enforces the limit; the full paged history is a * separate dive-in screen. */ val transactions: List = emptyList(), + val feedSyncState: FeedSyncState = FeedSyncState.Unknown, ) { val hasAddedMoney: Boolean - get() = onboardingItems.find { it is TutorialItem.AddMoney }?.isCompleted == true + get() = onboardingItems?.find { it is TutorialItem.AddMoney }?.isCompleted == true + /** Treated as complete while unknown, so the tutorial is never the thing we guess at. */ val isNewUserTutorialComplete: Boolean - get() = onboardingItems.all { it.isCompleted } + get() = onboardingItems?.all { it.isCompleted } != false + + /** + * Whether the activity half of the tab is still settling. + * + * Both the milestones and the recent-activity preview are reads of a *local cache* that + * starts empty on a fresh login, so neither can be trusted until the feed has been + * reconciled with the server at least once. Without this an established account signing in + * was shown the new-user tutorial for as long as its history took to arrive. Local rows + * short-circuit the wait: if there is already activity to draw, there is nothing to + * mistake for a new account. + */ + val isAwaitingActivity: Boolean + get() = onboardingItems == null || + (feedSyncState == FeedSyncState.Unknown && transactions.isEmpty()) } sealed interface Event { data class OnOnboardingItemsUpdated(val items: List): Event data class OnTransactionsUpdated(val transactions: List) : Event data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event + data class OnFeedSyncStateChanged(val syncState: FeedSyncState) : Event data object OpenCurrencySelection : Event @@ -73,6 +96,12 @@ internal class WalletViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnTransactionsUpdated(it)) } .launchIn(viewModelScope) + // Whether an empty feed means "nothing happened" or "we haven't looked yet" (see + // State.isAwaitingActivity). + feedCoordinator.syncState + .onEach { dispatchEvent(Event.OnFeedSyncStateChanged(it)) } + .launchIn(viewModelScope) + userManager.state .filter { it.authState is AuthState.Ready } .flatMapLatest { userFlags.resolvedFlags } @@ -114,6 +143,9 @@ internal class WalletViewModel @Inject constructor( is Event.OnPreferredOnRampProviderChanged -> { state -> state.copy(preferredOnRampProvider = event.provider) } + is Event.OnFeedSyncStateChanged -> { state -> + state.copy(feedSyncState = event.syncState) + } is Event.OnOnboardingItemsUpdated -> { state -> state.copy(onboardingItems = event.items) } diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt new file mode 100644 index 0000000000..0e3b5d35d9 --- /dev/null +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt @@ -0,0 +1,103 @@ +package com.flipcash.app.balance.internal + +import com.flipcash.app.balance.internal.components.TutorialItem +import com.flipcash.shared.transactionhistory.FeedSyncState +import com.flipcash.shared.transactionhistory.TransactionAvatar +import com.flipcash.shared.transactionhistory.TransactionListItem +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The wallet tab must not mistake a cold local cache for an empty account: on a fresh login the + * activity feed and the onboarding milestones both read empty until the first sync lands, and + * rendering that verdict greets an established user with the new-user tutorial. + */ +class WalletLoadingStateTest { + + private fun milestones(addedMoney: Boolean, tipped: Boolean) = listOf( + TutorialItem.AddMoney(isCompleted = addedMoney), + TutorialItem.ScanTipCard(isCompleted = tipped), + ) + + private val aTransaction = TransactionListItem( + id = "1", + title = "Received", + timestamp = Instant.fromEpochSeconds(0), + avatar = TransactionAvatar.Generic, + signedAmountPrefix = "+", + amount = null, + canCancel = false, + ) + + @Test + fun `awaits activity before the milestones have reported`() { + assertTrue(WalletViewModel.State().isAwaitingActivity) + } + + @Test + fun `still awaits activity when milestones report against an unsynced feed`() { + val state = WalletViewModel.State( + onboardingItems = milestones(addedMoney = false, tipped = false), + feedSyncState = FeedSyncState.Unknown, + ) + assertTrue(state.isAwaitingActivity) + } + + @Test + fun `stops awaiting once the feed has synced`() { + val state = WalletViewModel.State( + onboardingItems = milestones(addedMoney = false, tipped = false), + feedSyncState = FeedSyncState.Synced, + ) + assertFalse(state.isAwaitingActivity) + } + + @Test + fun `stops awaiting when the feed is unreachable rather than spinning forever`() { + val state = WalletViewModel.State( + onboardingItems = milestones(addedMoney = false, tipped = false), + feedSyncState = FeedSyncState.Unavailable, + ) + assertFalse(state.isAwaitingActivity) + } + + @Test + fun `cached rows short-circuit the wait — there is nothing to mistake for a new account`() { + val state = WalletViewModel.State( + onboardingItems = milestones(addedMoney = true, tipped = false), + transactions = listOf(aTransaction), + feedSyncState = FeedSyncState.Unknown, + ) + assertFalse(state.isAwaitingActivity) + } + + @Test + fun `tutorial is withheld while the milestones are unknown`() { + assertTrue(WalletViewModel.State().isNewUserTutorialComplete) + } + + @Test + fun `tutorial shows once a milestone is known to be outstanding`() { + val state = WalletViewModel.State( + onboardingItems = milestones(addedMoney = true, tipped = false), + feedSyncState = FeedSyncState.Synced, + ) + assertFalse(state.isNewUserTutorialComplete) + } + + @Test + fun `tutorial is complete when every milestone is`() { + val state = WalletViewModel.State( + onboardingItems = milestones(addedMoney = true, tipped = true), + feedSyncState = FeedSyncState.Synced, + ) + assertTrue(state.isNewUserTutorialComplete) + } + + @Test + fun `hasAddedMoney is false while unknown, gating the action tiles`() { + assertFalse(WalletViewModel.State().hasAddedMoney) + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 1d209d0067..1a11959a1e 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -14,9 +14,13 @@ import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.core.RandomId import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -42,11 +46,25 @@ class ChatMessageDataSource @Inject constructor( activeChatId = chatId } - /** Reactive "has the user ever sent a tip" — an outgoing Cash message (self) with verb TIPPED. */ - fun hasEverTipped(): Flow { - val selfHex = userManager.accountId?.hexEncodedString() ?: return flowOf(false) - return db?.chatMessageDao()?.hasEverTipped(selfHex) ?: flowOf(false) - } + /** + * Reactive "has the user ever sent a tip" — an outgoing Cash message (self) with verb TIPPED. + * + * Both inputs are resolved reactively rather than read once at subscription time. The per-user + * DB is created at login and the account id is set during it, so a subscriber that starts before + * either is ready (the wallet tab composing while a soft login is still in flight) used to latch + * onto a constant `false` for the whole session — pinning the onboarding checklist to + * "incomplete" and leaving the new-user tutorial on screen for an established account. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun hasEverTipped(): Flow = + combine( + FlipcashDatabase.observeInstance(), + userManager.state.map { it.accountId }.distinctUntilChanged(), + ) { database, accountId -> database to accountId } + .flatMapLatest { (database, accountId) -> + val selfHex = accountId?.hexEncodedString() ?: return@flatMapLatest flowOf(false) + database?.chatMessageDao()?.hasEverTipped(selfHex) ?: flowOf(false) + } // region PagingDataSource diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt index 29cc35ab69..e1c72dd556 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/MessageDataSource.kt @@ -60,9 +60,21 @@ class MessageDataSource @Inject constructor( db?.messageDao()?.upsert(*entities.toTypedArray()) } - /** Reactive "has the user ever added money" — any completed deposit/buy notification. */ + /** + * Reactive "has the user ever added money" — any completed deposit/buy notification. + * + * Resolved through [FlipcashDatabase.observeInstance] for the same reason as [observeRecent]: + * the per-user DB is created at login, *after* singletons have built their flow graphs. Reading + * `db` once at subscription time latched any subscriber that started before the DB existed onto + * a constant `false` for the rest of the session — which pinned the wallet's onboarding + * checklist to "incomplete" (and so kept the new-user tutorial on screen) no matter how much + * activity later landed in the feed. + */ + @OptIn(ExperimentalCoroutinesApi::class) fun hasEverAddedMoney(): Flow = - db?.messageDao()?.hasEverAddedMoney() ?: flowOf(false) + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.messageDao()?.hasEverAddedMoney() ?: flowOf(false) + } /** * Observes the [limit] most recent messages (newest first) as domain models. Reacts to the diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index 8d29d180a7..41156aa22d 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -36,17 +36,41 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +/** + * How far the activity feed has got in reconciling itself with the server for the signed-in user. + * + * Surfaces exist that must distinguish "this account has no activity" from "we haven't looked yet": + * the local feed is a cache that starts empty on every fresh install and login, so treating an empty + * cache as an empty account greets an established user with new-user onboarding. + */ +enum class FeedSyncState { + /** No fetch has completed yet this session — whatever is on screen is cache-only. */ + Unknown, + + /** A fetch succeeded: the local feed reflects the server, and an empty feed really is empty. */ + Synced, + + /** A fetch completed without success. Callers should stop waiting; the poller will retry. */ + Unavailable, +} + @Singleton class ActivityFeedCoordinator @Inject internal constructor( private val activityFeedController: ActivityFeedController, @@ -67,6 +91,25 @@ class ActivityFeedCoordinator @Inject internal constructor( // for the same user collapse to a single fetch. private val resolvingProfiles = ConcurrentHashMap.newKeySet() + private val _syncState = MutableStateFlow(FeedSyncState.Unknown) + + /** + * Whether the feed has been reconciled with the server this session. See [FeedSyncState]. + * Maintained by [fetchSinceLatest], which every refresh path funnels through. + */ + val syncState: StateFlow = _syncState.asStateFlow() + + init { + // Losing API access ends the sync's validity: the next account starts from Unknown so its + // wallet waits for a real fetch instead of rendering the previous session's verdict. + userManager.state + .map { it.authState.canAccessAuthenticatedApis } + .distinctUntilChanged() + .filter { !it } + .onEach { _syncState.value = FeedSyncState.Unknown } + .launchIn(scope) + } + @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) val messages: Flow> = userManager.state // Dedupe the auth gate so the Pager is built ONCE. Without distinctUntilChanged, every @@ -259,6 +302,20 @@ class ActivityFeedCoordinator @Inject internal constructor( token = latest?.id, descending = latest == null, ) - ).onSuccess { dataSource.upsert(it) }.map { Unit } + ) + .onSuccess { + dataSource.upsert(it) + // The per-user DB is opened before the auth gate that lets this call run at all, so + // a successful fetch here really has landed in the cache. + _syncState.value = FeedSyncState.Synced + } + // Don't downgrade a sync that already succeeded — a later failure just means this + // refresh missed, not that the cache is unknown again. + .onFailure { + _syncState.update { current -> + if (current == FeedSyncState.Unknown) FeedSyncState.Unavailable else current + } + } + .map { Unit } } -} \ No newline at end of file +}