Skip to content

Commit 8802fa9

Browse files
authored
feat(login): name account switcher rows by username, and load balances in two calls (#1626)
* feat(login): name account switcher rows by username or display name Each row in the account switcher now shows the account's @username, else its profile display name, else the mnemonic name it showed before. iOS uses the same order. Only the signed-in account has a session, so the other rows read from a new AccountProfileCache: a local DataStore keyed by owner public key, written from UserManager's state whenever a profile is restored, fetched, or edited. Reading it needs no network and no signing as the other account. The server's getProfile takes a user id and a signed request, so there was no unsigned lookup by owner key to use instead. Names stay out of the Block Store entry, which has a 4KB budget sized for fixed-width records. An account fills its cache entry the next time it signs in on this device; until then its row keeps the mnemonic name. * feat(login): fetch every listed account's username and display name The switcher named only accounts that had signed in on this device, since the cache was filled from the signed-in session. Each row now fetches its own profile, signed as that account, the way the list already fetches balances: Login maps the owner key to a user id, then GetProfile by that id. The cached names still show first, and offline. A NotFound profile counts as "no names", so a name the account has since dropped is cleared from the cache. Other failures leave the row's cached title in place. * perf(login): load account switcher balances in two calls per account An account holding 43 tokens made 43 sequential metadata calls of 100-200ms each before its balance showed. fetchTokenAccounts resolves metadata one mint at a time, which is cheap behind the main screen's cache and a round trip per token here, where there is no cache. TokenController.fetchTokenBalances makes one account lookup and one batched metadata lookup, the shape iOS's account list uses. A mint the batch does not return fails the fetch instead of shortening the total. The list also showed nothing until every stored account's key was derived, one PBKDF2 run and SLIP-10 chain after another. Those now run side by side on the Default dispatcher. * feat(login): show a shimmer where an account's balance is loading * feat(login): mark accounts with the app's checked/unchecked circle The rows used a bare tick on the current account only. They now use the ic_checked/ic_unchecked pair the region, token and group-invite pickers use, which matches iOS's CheckView.
1 parent daa35ad commit 8802fa9

11 files changed

Lines changed: 624 additions & 49 deletions

File tree

‎apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountRow.kt‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
package com.flipcash.app.login.internal.accounts
22

33
import androidx.compose.foundation.ExperimentalFoundationApi
4+
import androidx.compose.foundation.Image
45
import androidx.compose.foundation.combinedClickable
56
import androidx.compose.foundation.layout.Arrangement
7+
import androidx.compose.foundation.layout.Box
68
import androidx.compose.foundation.layout.Column
79
import androidx.compose.foundation.layout.Row
810
import androidx.compose.foundation.layout.fillMaxWidth
911
import androidx.compose.foundation.layout.padding
1012
import androidx.compose.foundation.layout.size
1113
import androidx.compose.material.Text
12-
import androidx.compose.material.icons.Icons
13-
import androidx.compose.material.icons.filled.Check
14-
import androidx.compose.material3.Icon
1514
import androidx.compose.runtime.Composable
1615
import androidx.compose.ui.Alignment
1716
import androidx.compose.ui.Modifier
18-
import androidx.compose.ui.graphics.Color
17+
import androidx.compose.ui.res.painterResource
1918
import androidx.compose.ui.res.stringResource
2019
import androidx.compose.ui.text.style.TextOverflow
20+
import androidx.compose.ui.unit.dp
21+
import com.flipcash.app.core.ui.shimmer
2122
import com.flipcash.features.login.R
2223
import com.getcode.theme.CodeTheme
2324

@@ -43,17 +44,16 @@ internal fun AccountRow(
4344
verticalAlignment = Alignment.CenterVertically,
4445
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
4546
) {
46-
// The tick is laid out for every row so the names line up; it is only drawn, and only
47-
// announced, for the account the user is signed into.
48-
Icon(
49-
modifier = Modifier.size(CodeTheme.dimens.grid.x4),
50-
imageVector = Icons.Default.Check,
47+
// The same mark as the region, token and group-invite pickers.
48+
Image(
49+
painter = painterResource(
50+
if (isCurrent) R.drawable.ic_checked else R.drawable.ic_unchecked
51+
),
5152
contentDescription = if (isCurrent) {
5253
stringResource(R.string.subtitle_currentAccount)
5354
} else {
5455
null
5556
},
56-
tint = if (isCurrent) CodeTheme.colors.textMain else Color.Transparent,
5757
)
5858

5959
Column(
@@ -81,7 +81,7 @@ internal fun AccountRow(
8181
}
8282

8383
// Order matters: a resolved balance wins, then the backend's own "no such account", then
84-
// our inability to ask. An account whose balance we could not fetch must not be reported as
84+
// our inability to ask, and only then is the fetch still in flight. An account whose balance we could not fetch must not be reported as
8585
// not found — the two say very different things to someone checking their own wallet.
8686
when {
8787
account.balance != null -> Text(
@@ -101,6 +101,15 @@ internal fun AccountRow(
101101
style = CodeTheme.typography.textSmall,
102102
color = CodeTheme.colors.textSecondary,
103103
)
104+
105+
// The skeleton the discovery list uses, sized to a short balance on the textMedium
106+
// line. It carries no semantics: TalkBack announces the balance when it lands.
107+
else -> Box(
108+
Modifier
109+
.size(width = 60.dp, height = 14.dp)
110+
.shimmer()
111+
)
104112
}
105113
}
106114
}
115+

‎apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/accounts/AccountSelectionViewModel.kt‎

Lines changed: 86 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package com.flipcash.app.login.internal.accounts
22

33
import androidx.lifecycle.viewModelScope
44
import com.flipcash.app.auth.AuthManager
5+
import com.flipcash.app.auth.internal.accounts.AccountProfileFetcher
6+
import com.flipcash.app.auth.internal.accounts.AccountProfileName
57
import com.flipcash.app.auth.internal.accounts.AccountRecord
8+
import com.flipcash.services.models.asHandle
69
import com.flipcash.features.login.R
710
import com.flipcash.libs.coroutines.DispatcherProvider
811
import com.getcode.crypt.DerivePath
@@ -15,11 +18,8 @@ import com.getcode.opencode.managers.MnemonicManager
1518
import com.getcode.opencode.model.accounts.AccountCluster
1619
import com.getcode.opencode.model.financial.Fiat
1720
import com.getcode.opencode.model.financial.Token
18-
import com.getcode.opencode.model.financial.TokenResult
1921
import com.getcode.opencode.model.financial.sum
2022
import com.getcode.opencode.model.financial.usdf
21-
import com.getcode.opencode.providers.TokenMetadataProvider
22-
import com.getcode.solana.keys.Mint
2323
import com.getcode.solana.keys.base58
2424
import com.getcode.util.resources.ResourceHelper
2525
import com.getcode.utils.TraceType
@@ -28,9 +28,10 @@ import com.getcode.view.BaseViewModel
2828
import dagger.hilt.android.lifecycle.HiltViewModel
2929
import kotlinx.coroutines.ExperimentalCoroutinesApi
3030
import kotlinx.coroutines.FlowPreview
31+
import kotlinx.coroutines.async
32+
import kotlinx.coroutines.awaitAll
3133
import kotlinx.coroutines.flow.Flow
3234
import kotlinx.coroutines.flow.asFlow
33-
import kotlinx.coroutines.flow.emptyFlow
3435
import kotlinx.coroutines.flow.filter
3536
import kotlinx.coroutines.flow.filterIsInstance
3637
import kotlinx.coroutines.flow.flatMapLatest
@@ -39,8 +40,10 @@ import kotlinx.coroutines.flow.flow
3940
import kotlinx.coroutines.flow.flowOn
4041
import kotlinx.coroutines.flow.launchIn
4142
import kotlinx.coroutines.flow.map
43+
import kotlinx.coroutines.flow.merge
4244
import kotlinx.coroutines.flow.onEach
4345
import kotlinx.coroutines.flow.onStart
46+
import kotlinx.coroutines.withContext
4447
import java.util.concurrent.atomic.AtomicBoolean
4548
import javax.inject.Inject
4649

@@ -50,6 +53,7 @@ class AccountSelectionViewModel @Inject constructor(
5053
private val authManager: AuthManager,
5154
private val mnemonicManager: MnemonicManager,
5255
private val tokenController: TokenController,
56+
private val profileFetcher: AccountProfileFetcher,
5357
private val resources: ResourceHelper,
5458
private val dispatchers: DispatcherProvider,
5559
) : BaseViewModel<AccountSelectionViewModel.State, AccountSelectionViewModel.Event>(
@@ -58,28 +62,6 @@ class AccountSelectionViewModel @Inject constructor(
5862
defaultDispatcher = dispatchers.Default,
5963
) {
6064

61-
/**
62-
* TokenController does not implement [TokenMetadataProvider] itself (only TokenCoordinator
63-
* does, scoped to the single signed-in user, which is wrong for this screen's other, dormant
64-
* accounts), so this screen brings its own, delegating to TokenController's network-tier
65-
* lookup with no cache — there is no warm cache here to benefit from anyway.
66-
*
67-
* One instance per account fetch, because it also records whether any lookup failed.
68-
* `fetchTokenAccounts` resolves metadata through `mapNotNull`, so a failed lookup silently
69-
* drops that token: without this flag an account whose metadata we could not read is
70-
* indistinguishable from one the backend has never heard of, and the row would say
71-
* "Not Found" about a wallet that exists.
72-
*/
73-
private inner class RecordingMetadataProvider : TokenMetadataProvider {
74-
var failed: Boolean = false
75-
private set
76-
77-
override suspend fun getTokenMetadata(mint: Mint): Result<TokenResult> =
78-
tokenController.getTokenMetadata(mint).onFailure { failed = true }
79-
80-
override fun observeTokenCache(): Flow<Map<Mint, Token>> = emptyFlow()
81-
}
82-
8365
/**
8466
* One row. All three result fields false/null is the in-flight state.
8567
*
@@ -96,13 +78,18 @@ class AccountSelectionViewModel @Inject constructor(
9678
*/
9779
val id: String,
9880
val entropy: String,
99-
val name: String,
81+
/** The mnemonic name, the title of last resort. */
82+
val mnemonicName: String,
83+
val username: String? = null,
84+
val displayName: String? = null,
10085
val ownerAddress: String,
10186
val creationDate: Long,
10287
val balance: Fiat? = null,
10388
val notFound: Boolean = false,
10489
val balanceUnavailable: Boolean = false,
10590
) {
91+
val name: String get() = title(username, displayName, mnemonicName)
92+
10693
// The default data class toString() would put a seed into every log line and crash report
10794
// that touches this state.
10895
override fun toString(): String = "AccountUiModel(owner=$ownerAddress)"
@@ -123,6 +110,7 @@ class AccountSelectionViewModel @Inject constructor(
123110
val currentEntropy: String?,
124111
) : Event
125112

113+
data class OnProfileResolved(val entropy: String, val name: AccountProfileName) : Event
126114
data class OnBalanceResolved(val entropy: String, val balance: Fiat) : Event
127115
data class OnBalanceNotFound(val entropy: String) : Event
128116
data class OnBalanceUnavailable(val entropy: String) : Event
@@ -143,16 +131,19 @@ class AccountSelectionViewModel @Inject constructor(
143131
.onStart { emit(Event.Load) }
144132
.map { derive(authManager.accounts.all()) }
145133
.onEach { derived ->
134+
val profiles = authManager.accountProfiles.all()
146135
dispatchEvent(
147136
Event.OnAccountsLoaded(
148137
accounts = derived.map { (record, cluster) ->
149-
record.toUiModel(cluster.getOrNull())
138+
record.toUiModel(cluster.getOrNull(), profiles)
150139
},
151140
currentEntropy = authManager.currentEntropy,
152141
)
153142
)
154143
}
155-
.flatMapLatest { derived -> derived.asFlow().flatMapMerge { balance(it) } }
144+
.flatMapLatest { derived ->
145+
derived.asFlow().flatMapMerge { merge(balance(it), profile(it)) }
146+
}
156147
.onEach { dispatchEvent(it) }
157148
// Derivation is PBKDF2 plus a SLIP-10 chain, and the fetches are network calls;
158149
// neither belongs on the thread drawing the list.
@@ -219,18 +210,35 @@ class AccountSelectionViewModel @Inject constructor(
219210
* stretch plus a SLIP-10 chain, and this list can hold 50 accounts. A record that will not
220211
* derive is kept rather than dropped — the user should still see it.
221212
*/
222-
private fun derive(records: List<AccountRecord>): List<Pair<AccountRecord, Result<AccountCluster>>> =
223-
records.map { record -> record to runCatching { clusterFor(record.entropy) } }
213+
/**
214+
* Each account's key costs a PBKDF2 run and a SLIP-10 chain, and nothing on screen can show
215+
* until every one is done, so they run side by side on the CPU pool instead of one after
216+
* another.
217+
*/
218+
private suspend fun derive(
219+
records: List<AccountRecord>,
220+
): List<Pair<AccountRecord, Result<AccountCluster>>> = withContext(dispatchers.Default) {
221+
records.map { record ->
222+
async { record to runCatching { clusterFor(record.entropy) } }
223+
}.awaitAll()
224+
}
224225

225-
private fun AccountRecord.toUiModel(cluster: AccountCluster?): AccountUiModel {
226+
private fun AccountRecord.toUiModel(
227+
cluster: AccountCluster?,
228+
profiles: Map<String, AccountProfileName>,
229+
): AccountUiModel {
226230
val mnemonic = runCatching { mnemonicManager.fromEntropyBase64(entropy) }.getOrNull()
227231
val owner = cluster?.authorityPublicKey?.base58()
232+
val profile = owner?.let { profiles[it] }
228233
return AccountUiModel(
229234
// A record that will not derive has no owner key to be keyed on; its creation
230235
// timestamp is the only other thing distinguishing it.
231236
id = owner ?: "underived-$creationDate",
232237
entropy = entropy,
233-
name = mnemonic?.let { displayName(it) }.orEmpty(),
238+
// The cached names show at once and offline; the fetch replaces them when it lands.
239+
mnemonicName = mnemonic?.let { displayName(it) }.orEmpty(),
240+
username = profile?.username,
241+
displayName = profile?.displayName,
234242
ownerAddress = owner?.let { truncateAddress(it) }.orEmpty(),
235243
creationDate = creationDate,
236244
// A record we cannot derive from is one we can never fetch a balance for.
@@ -262,13 +270,12 @@ class AccountSelectionViewModel @Inject constructor(
262270
return@flow emit(Event.OnBalanceUnavailable(record.entropy))
263271
}
264272

265-
val metadata = RecordingMetadataProvider()
266-
tokenController.fetchTokenAccounts(owner, metadata)
273+
// Batched: one account lookup and one metadata lookup, however many tokens the account
274+
// holds. It fails outright rather than drop a token whose metadata did not come back, so a
275+
// short total is never shown as the balance.
276+
tokenController.fetchTokenBalances(owner)
267277
.onSuccess { tokens ->
268278
when {
269-
// A dropped token makes the total wrong as surely as it makes an empty list
270-
// meaningless, so a failed lookup rules out both other answers.
271-
metadata.failed -> emit(Event.OnBalanceUnavailable(record.entropy))
272279
tokens.isEmpty() -> emit(Event.OnBalanceNotFound(record.entropy))
273280
else -> emit(
274281
Event.OnBalanceResolved(
@@ -289,9 +296,38 @@ class AccountSelectionViewModel @Inject constructor(
289296
}
290297
}
291298

299+
/**
300+
* One account's username and display name, fetched signed as that account, so every row is
301+
* named and not just the signed-in one. A failure keeps whatever the cache gave the row.
302+
*/
303+
private fun profile(entry: Pair<AccountRecord, Result<AccountCluster>>): Flow<Event> = flow {
304+
val (record, cluster) = entry
305+
val owner = cluster.getOrNull() ?: return@flow
306+
profileFetcher.fetch(owner.authority.keyPair, owner.authorityPublicKey.base58())
307+
.onSuccess { emit(Event.OnProfileResolved(record.entropy, it)) }
308+
.onFailure { error ->
309+
trace(
310+
tag = TAG,
311+
message = "Profile fetch failed for a stored account",
312+
error = error,
313+
type = TraceType.Error,
314+
)
315+
}
316+
}
317+
292318
companion object {
293319
private const val TAG = "AccountSelection"
294320

321+
/**
322+
* A row's title: the account's `@username`, else its profile display name, else the
323+
* mnemonic name. iOS picks in the same order, so an account reads the same on both.
324+
* Blank counts as absent, so a cleared name falls through rather than showing nothing.
325+
*/
326+
fun title(username: String?, displayName: String?, mnemonicName: String): String =
327+
username?.takeIf { it.isNotBlank() }?.asHandle()
328+
?: displayName?.takeIf { it.isNotBlank() }
329+
?: mnemonicName
330+
295331
/** iOS's `MnemonicPhrase.name`: first word, ellipsis, last word, both capitalised. */
296332
fun displayName(mnemonic: MnemonicPhrase): String =
297333
listOf(mnemonic.words.first(), mnemonic.words.last())
@@ -313,6 +349,18 @@ class AccountSelectionViewModel @Inject constructor(
313349
)
314350
}
315351

352+
is Event.OnProfileResolved -> { state ->
353+
state.copy(
354+
accounts = state.accounts.map {
355+
if (it.entropy == event.entropy) {
356+
it.copy(username = event.name.username, displayName = event.name.displayName)
357+
} else {
358+
it
359+
}
360+
}
361+
)
362+
}
363+
316364
is Event.OnBalanceResolved -> { state ->
317365
state.copy(
318366
accounts = state.accounts.map {

0 commit comments

Comments
 (0)