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 9fb8ccc30..8ce42fbd7 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 5e6967094..cea62b350 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 000000000..0e3b5d35d --- /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 1d209d006..1a11959a1 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 29cc35ab6..e1c72dd55 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 8d29d180a..41156aa22 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 +}