Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,25 +42,47 @@ internal class WalletViewModel @Inject constructor(
) {
data class State(
val preferredOnRampProvider: OnRampProvider.Defined? = null,
val onboardingItems: List<TutorialItem> = 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<TutorialItem>? = 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<TransactionListItem> = 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<TutorialItem>): Event
data class OnTransactionsUpdated(val transactions: List<TransactionListItem>) : Event
data class OnPreferredOnRampProviderChanged(val provider: OnRampProvider.Defined?) : Event
data class OnFeedSyncStateChanged(val syncState: FeedSyncState) : Event

data object OpenCurrencySelection : Event

Expand All @@ -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 }
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Boolean> {
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<Boolean> =
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> =
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -67,6 +91,25 @@ class ActivityFeedCoordinator @Inject internal constructor(
// for the same user collapse to a single fetch.
private val resolvingProfiles = ConcurrentHashMap.newKeySet<String>()

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<FeedSyncState> = _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<PagingData<ActivityFeedMessage>> = userManager.state
// Dedupe the auth gate so the Pager is built ONCE. Without distinctUntilChanged, every
Expand Down Expand Up @@ -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 }
}
}
}
Loading