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 numberDiff line numberDiff line change
Expand Up@@ -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()
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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.
Expand DownExpand Up@@ -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<TokenSyncState> = _syncState.asStateFlow()

val tokens: Flow<List<Token>> = _state.map { it.tokens.values.toList() }

/** Cache-only, network-free view of the in-memory token map (see [TokenMetadataProvider]). */
Expand DownExpand Up@@ -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()
}

Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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
}
}
}

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -50,7 +51,25 @@ class SelectTokenViewModel @Inject constructor(
val discoveryEnabled: Boolean = false,
val tokens: List<TokenWithLocalizedBalance>? = 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
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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)) }
Expand DownExpand Up@@ -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) }
}
}
}
Expand Down
Loading