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 93c43719f..7e573b148 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 @@ -77,7 +77,13 @@ internal fun WalletScreenContent( // 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) { + // + // The two waits are independent races, and the activity preview *reads* the token cache: a + // convert row titles itself "USDF -> Dad Cash" from both mints' metadata and falls back to the + // server's bare "Converted" until they resolve. Post-login the feed regularly won that race, so + // the tab drew converts as "Converted" and then re-titled them once tokens landed. Waiting on + // both (see State.isAwaitingTokens / isAwaitingActivity) means the section is drawn once, resolved. + if (tokenState.isAwaitingTokens || balanceState.isAwaitingActivity) { Box( modifier = Modifier .fillMaxSize() diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index a664bfced..a8671914c 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -48,6 +48,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -64,6 +66,27 @@ import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +/** + * How far the token set has got in reconciling itself with the server for the signed-in user. + * + * Mirrors the activity feed's `FeedSyncState`, and for the same reason: the token cache is a *cache* + * that starts empty on every fresh install and login, so an empty token map means "we haven't looked + * yet" just as often as it means "this account holds nothing". Surfaces that render token *metadata* + * (names, icons) for data arriving from elsewhere — the activity feed's convert rows, which read + * "USDF -> Dad Cash" only once both mints resolve — must wait for a real fetch rather than draw their + * server-text fallback ("Converted") against a not-yet-populated cache. + */ +enum class TokenSyncState { + /** No token fetch has completed yet this session — whatever is cached is cache-only. */ + Unknown, + + /** A fetch succeeded: the in-memory token map reflects the server. */ + Synced, + + /** A fetch completed without success. Callers should stop waiting; the next trigger retries. */ + Unavailable, +} + /** * App-layer coordinator that wraps [TokenController] with persistence, * caching, lifecycle management, and balance tracking. @@ -117,6 +140,14 @@ class TokenCoordinator @Inject constructor( private val _state = MutableStateFlow(TokenState()) private val _hydrated = MutableStateFlow(false) + private val _syncState = MutableStateFlow(TokenSyncState.Unknown) + + /** + * Whether the token set has been reconciled with the server this session. See [TokenSyncState]. + * Maintained by [updateTokens], which every full refresh funnels through. + */ + val syncState: StateFlow = _syncState.asStateFlow() + val tokens: Flow> = _state.map { it.tokens.values.toList() } /** Cache-only, network-free view of the in-memory token map (see [TokenMetadataProvider]). */ @@ -147,6 +178,8 @@ class TokenCoordinator @Inject constructor( override suspend fun onUserLoggedIn(cluster: AccountCluster) { trace(tag = TAG, message = "User logged in, hydrating from persistence", type = TraceType.User) this.cluster.value = cluster + // A new session hasn't looked at the server yet, whatever the previous one concluded. + _syncState.value = TokenSyncState.Unknown hydrateFromPersistence() } @@ -342,6 +375,7 @@ class TokenCoordinator @Inject constructor( val previousTokenCount = _state.value.tokens.size _state.value = TokenState() _hydrated.value = false + _syncState.value = TokenSyncState.Unknown cluster.value = null selectedToken.edit { it.clear() } dataSource.clear() @@ -401,9 +435,15 @@ class TokenCoordinator @Inject constructor( applyTokenUpdates(updates) persistTokenState(updates) ensureValidTokenSelection() + _syncState.value = TokenSyncState.Synced } .onFailure { error -> trace(tag = TAG, message = "Failed to update tokens: ${error.message}", type = TraceType.Error) + // Don't downgrade a sync that already succeeded — a later failure means this refresh + // missed, not that the cache is unknown again. + _syncState.update { current -> + if (current == TokenSyncState.Unknown) TokenSyncState.Unavailable else current + } } } diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index f7f5bd1bf..1eb883a36 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -6,6 +6,7 @@ import com.flipcash.app.core.tokens.TokenPurpose import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.tokens.TokenSyncState import com.flipcash.shared.tokens.R import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Fiat @@ -50,7 +51,25 @@ class SelectTokenViewModel @Inject constructor( val discoveryEnabled: Boolean = false, val tokens: List? = null, val selectedToken: Mint? = null, + val syncState: TokenSyncState = TokenSyncState.Unknown, ) { + /** + * Whether the token set is still settling. + * + * Null tokens means persistence hasn't reported yet. An *empty* set is the ambiguous case: + * the token cache starts empty on a fresh login, so until a fetch has actually completed + * (see [TokenSyncState]) "no tokens" is indistinguishable from "not looked yet". Callers that + * render token metadata for data sourced elsewhere — the wallet's recent-activity preview, + * whose convert rows read "USDF -> Dad Cash" only once both mints resolve — must wait it out + * rather than draw the unresolved fallback ("Converted"). A non-empty set short-circuits the + * wait: there is already token metadata to resolve against. + */ + val isAwaitingTokens: Boolean + get() { + val set = tokens ?: return true + return set.isEmpty() && syncState == TokenSyncState.Unknown + } + val totalBalance: LocalFiat? get() { val set = tokens ?: return null @@ -86,6 +105,8 @@ class SelectTokenViewModel @Inject constructor( data class OpenScreen(val route: AppRoute) : Event data class OnCanGiveUsdf(val enabled: Boolean) : Event + + data class OnSyncStateChanged(val syncState: TokenSyncState) : Event } init { @@ -219,6 +240,12 @@ class SelectTokenViewModel @Inject constructor( }.onEach { dispatchEvent(Event.OnTokensUpdated(it)) } .launchIn(viewModelScope) + // Whether an empty token set means "holds nothing" or "we haven't looked yet" + // (see State.isAwaitingTokens). + tokenCoordinator.syncState + .onEach { dispatchEvent(Event.OnSyncStateChanged(it)) } + .launchIn(viewModelScope) + tokenCoordinator.observeSelectedTokenMint() .distinctUntilChanged() .onEach { dispatchEvent(Event.OnTokenSelected(it, fromUser = false)) } @@ -247,6 +274,7 @@ class SelectTokenViewModel @Inject constructor( is Event.OnTokenChanged -> { state -> state } is Event.OpenScreen -> { state -> state } is Event.OnCanGiveUsdf -> { state -> state.copy(canGiveUsdf = event.enabled) } + is Event.OnSyncStateChanged -> { state -> state.copy(syncState = event.syncState) } } } }