feat(ui): the switcher, showing the nostr profile and what the device holds for it

Phase 4 of docs/multiple-profiles.md. One pushed screen, and the startup
selector brought up to match it.

ProfilesRoute, reached from the profile tab's row -- "Switch profile", where
it said "Change account" and went to the pending screen -- and drawn by
ProfilesScreen: a top app bar with a back button, and WalletsSelector as its
whole content with the open profile above the divider and the others below.
Tapping another row is switchToIdentity and nothing else; the observer
restarts the app as that identity through its lock gate, so the screen is
never around to see the result. Tapping the open row does nothing. Three
states, said so: loading while the list is read, which in practice is never
seen; an unreadable key file, through ErrorState with a retry that re-lists;
and the list. Empty cannot happen while something is signed in and the screen
does not pretend it can. One column at every width. The row is offered to
every kind: a read-only identity can leave for another profile the same way
a signing one can.

ProfilesViewModel joins the flows the app already holds -- the listing state,
the identities, the active identity, the wallet metadata -- to the profile
rows for the listed keys, through NostrRepository.observeProfilesOf, one map
that moves when any of them does. Nothing in it writes.

What a row shows changes. WalletsSelector showed "Default name" over a random
emoji for every row, because nothing in this app writes the wallet metadata's
name, and two profiles side by side both called Default name and told apart
by a bech32 string is not a switcher. The row now takes the nostr profile --
humanReadableNameOrPubkey over ProfileAvatar, the widgets the profile tab
draws itself with -- with the npub on the second line, and falls back to the
npub over the metadata's emoji for a key the device has no kind 0 for yet:
one just created, or a read-only one that was never found. And it says what
the device holds for the profile before the tap: "Wallet" for a seed
attached, beside the "Read only" that was already there, and nothing for the
plain case of a bare key. It is the difference between a profile that can be
signed out of and one whose sign-out is a wallet question, and between two
profiles with the same name. The startup screen draws the same widget and
gets the same map through rememberProfilesOf, so both lists look the same.
The selector's dead globalPrefs parameter goes with the rewrite.

Tests: ProfilesScreenJvmTest, on the unmerged tree, with a wallet-attached A
open, a bare B and a read-only C -- A by its nostr name with its npub and
"Wallet" under it, B and C by their npub with only C labelled, no "Default
name" anywhere, the open one drawn above the others; tapping A calls
nothing and tapping B calls the switch once with B's id; an unreadable key
file is an error whose retry is counted; the bar has a back button.
ReadOnlyEntrancesJvmTest asserts "Switch profile" for both kinds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@25464595f4
This commit is contained in:
Kgothatso Ngako
2026-09-13 00:50:37 +02:00
parent 73a0e5b9c2
commit 5654e4624b
13 changed files with 547 additions and 24 deletions

View File

@@ -42,7 +42,6 @@
<string name="bip39_seed_with_the_standard_bip84">BIP39 seed with the standard BIP84 derivation path. The profile\'s nostr key comes off the same seed, so these 12 words restore both.</string>
<string name="block_user">Block user</string>
<string name="cancel">Cancel</string>
<string name="change_account">Change account</string>
<string name="chapter_detail">Chapter detail</string>
<string name="chapter_name">Chapter name</string>
<string name="chapter_translation">Chapter translation</string>
@@ -478,4 +477,8 @@
<string name="preparing_profiles">Preparing profiles…</string>
<string name="starting_wallet">Starting wallet…</string>
<string name="unlock_to_continue">Unlock to continue</string>
<!-- The switcher: the profiles on this device, and what the device holds for each. -->
<string name="loading_profiles">Loading profiles…</string>
<string name="switch_profile">Switch profile</string>
<string name="wallet">Wallet</string>
</resources>

View File

@@ -0,0 +1,24 @@
package press.mantra.compose.repository
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import press.mantra.compose.database.model.Profile
/**
* The nostr profile of each of [publicKeys], as one map that moves when any of them
* does. For the two lists of the device's profiles -- the switcher and the startup
* selector -- which show a name and a picture where the device has a kind 0 and the
* npub where it does not.
*/
fun NostrRepository.observeProfilesOf(publicKeys: Collection<HexKey>): Flow<Map<HexKey, Profile?>> {
val keys = publicKeys.distinct()
if (keys.isEmpty()) return flowOf(emptyMap())
val flows = keys.map { publicKey ->
flow { emitAll(observeProfileWithPublicKey(publicKey)) }
}
return combine(flows) { profiles -> keys.zip(profiles.toList()).toMap() }
}

View File

@@ -54,7 +54,6 @@ import press.mantra.compose.ui.composable.widgets.LocalCanSign
import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.change_account
import mantra.composeapp.generated.resources.edit_profile
import mantra.composeapp.generated.resources.key_package_management
import mantra.composeapp.generated.resources.network_relays
@@ -62,6 +61,8 @@ import mantra.composeapp.generated.resources.profile
import mantra.composeapp.generated.resources.key_recovery
import mantra.composeapp.generated.resources.share_profile
import mantra.composeapp.generated.resources.sign_out
import mantra.composeapp.generated.resources.switch_profile
import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute
import mantra.composeapp.generated.resources.sign_out_of_this_profile_question
import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again
import press.mantra.compose.identity.Identity
@@ -302,18 +303,18 @@ fun ActiveProfileScreen(
}
// For every kind: a read-only identity can leave for another
// profile the same way a signing one can.
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Change account")
)
onNavigateToRoute.invoke(ProfilesRoute)
}
) {
Icon(
Icons.Default.ImportExport,
contentDescription = "Change profile"
contentDescription = Decorative
)
Spacer(
@@ -321,7 +322,7 @@ fun ActiveProfileScreen(
)
Text(
stringResource(Res.string.change_account)
stringResource(Res.string.switch_profile)
)
}
}

View File

@@ -0,0 +1,92 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.loading_profiles
import mantra.composeapp.generated.resources.profiles
import fr.acinq.phoenix.data.WalletId
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.ui.composable.widgets.ErrorState
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.view.model.ProfilesViewModel
import press.mantra.compose.ui.view.state.ProfilesUIState
/**
* The profiles on this device -- the one that is open above a divider, the others
* below -- and the way from one to another. Phase 4 of docs/multiple-profiles.md.
*
* Tapping another row is [onSwitch] and nothing else: the navigation observer restarts
* the app as that identity, through its lock gate, so this screen is never around to
* see the result. Tapping the current row does nothing. A pushed screen behind one
* deliberate tap from the profile tab, and never a global control, because a switch
* clears the back stack from wherever the user is.
*
* One column at every width: a list of a handful of rows has no detail to pair with.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesScreen(
viewModel: ProfilesViewModel,
onNavigateBack: () -> Unit,
onSwitch: (WalletId) -> Unit,
/** Re-lists. An unreadable key file is exactly the failure a retry can answer. */
onRetry: () -> Unit,
/** The two ways to add a profile, drawn under the list; Phase 5 fills the slot. */
addProfile: (@Composable () -> Unit)? = null,
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.profiles)) },
navigationIcon = { NavigateBackButton(onNavigateBack) },
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.readableContent(),
) {
ScreenStateTransition(uiState) { state ->
when (state) {
is ProfilesUIState.Loading -> LoadingDataIndicator(
text = stringResource(Res.string.loading_profiles),
)
is ProfilesUIState.Error -> ErrorState(onRetry = onRetry)
is ProfilesUIState.Loaded -> WalletsSelector(
modifier = Modifier.fillMaxSize().padding(horizontal = MaterialTheme.spacing.screenMargin),
wallets = state.identities,
walletsMetadata = state.walletsMetadata,
activeWalletId = state.activeId,
canEdit = false,
onWalletClick = { onSwitch(it.id) },
profiles = state.profiles,
bottomContent = addProfile,
)
}
}
}
}
}

View File

@@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.first
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import press.mantra.compose.identity.StoredIdentity
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.widgets.wallet.rememberProfilesOf
import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
@@ -46,6 +48,8 @@ import press.mantra.compose.ui.view.state.StartupChoice
@Composable
fun SovereignWalletStartupScreen(
sovereignWalletViewModel: press.mantra.compose.ui.view.model.SovereignWalletViewModel,
/** For the selector's rows: the nostr profile of each listed key, where the device has one. */
nostrRepository: NostrRepository,
onNavigateToWalletLandingPage: () -> Unit,
onSuccessfulStartup: () -> Unit,
forceWalletId: WalletId?,
@@ -139,11 +143,15 @@ fun SovereignWalletStartupScreen(
}
when (val stored = loadingIdentity) {
null -> {
val profiles by rememberProfilesOf(
publicKeys = availableIdentities.values.map { it.nostrPublicKey },
nostrRepository = nostrRepository,
)
press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector(
wallets = availableIdentities,
globalPrefs = globalPrefs,
walletsMetadata = availableWalletMetadata,
activeWalletId = null,
profiles = profiles,
onWalletClick = {
sovereignWalletViewModel.switchToIdentity(
it.id

View File

@@ -85,7 +85,11 @@ import press.mantra.compose.ui.composable.navigation.routes.LandingRoute
import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute
import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute
import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute
import press.mantra.compose.ui.composable.ProfilesScreen
import press.mantra.compose.ui.view.model.ProfilesViewModel
import press.mantra.compose.ui.view.model.getAvailableWalletsMeta
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
@@ -455,6 +459,7 @@ fun MantraNavHost(
SovereignWalletStartupScreen(
sovereignWalletViewModel = sovereignWalletViewModel,
nostrRepository = databaseNostrRepository,
// No seed on this device, so there is no wallet to start: the user has to make or
// restore one, and that lives behind the landing screen. This used to park them on
// a loading screen with nothing left to load.
@@ -889,6 +894,31 @@ fun MantraNavHost(
}
)
}
composable<ProfilesRoute> {
val profilesViewModel: ProfilesViewModel = viewModel(
factory = ProfilesViewModel.factory(
listWalletState = sovereignWalletViewModel.listWalletState,
availableIdentities = sovereignWalletViewModel.availableIdentities,
activeIdentity = sovereignWalletViewModel.activeIdentity,
walletsMetadata = getAvailableWalletsMeta(phoenixGlobal),
nostrRepository = databaseNostrRepository,
),
)
ProfilesScreen(
viewModel = profilesViewModel,
onNavigateBack = {
navController.popBackStack()
},
// The switch is the whole of it: the observer above sends the null
// identity to startup, and startup opens the one named.
onSwitch = { walletId ->
sovereignWalletViewModel.switchToIdentity(walletId)
},
onRetry = {
sovereignWalletViewModel.listIdentities {}
},
)
}
composable<KeyRecoveryRoute> { backStackEntry ->
val route = backStackEntry.toRoute<KeyRecoveryRoute>()

View File

@@ -0,0 +1,11 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* The profiles on this device: the one that is open, the others, and the two ways to
* add one. Pushed from the profile tab; no arguments, since everything it shows is the
* device's rather than a route's. See docs/multiple-profiles.md, Phase 4.
*/
@Serializable
object ProfilesRoute : Route()

View File

@@ -0,0 +1,23 @@
package press.mantra.compose.ui.composable.widgets.wallet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.Profile
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.repository.observeProfilesOf
/**
* The nostr profiles of [publicKeys], for a screen that has no view model to hold them
* -- the startup screen, whose own state machine is the wallet's. The switcher gets the
* same map from `ProfilesViewModel`; both draw `WalletsSelector`, and both lists should
* look the same.
*/
@Composable
fun rememberProfilesOf(publicKeys: Collection<HexKey>, nostrRepository: NostrRepository): State<Map<HexKey, Profile?>> {
val keys = publicKeys.toSet()
return produceState<Map<HexKey, Profile?>>(initialValue = emptyMap(), keys, nostrRepository) {
nostrRepository.observeProfilesOf(keys).collect { value = it }
}
}

View File

@@ -44,26 +44,37 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import press.mantra.compose.ui.composable.widgets.buttons.Clickable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
import press.mantra.compose.extensions.hexToNpubHrp
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.read_only
import mantra.composeapp.generated.resources.wallet
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.database.model.Profile
import press.mantra.compose.identity.StoredIdentity
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
import press.mantra.compose.ui.theme.spacing
/**
* The device's profiles as a list: the open one above a divider, the others below,
* each with its nostr name and picture where the device holds a kind 0 for it and its
* npub where it does not.
*
* @param profiles the nostr profile of each listed key, by public key; a key with no
* entry, or a null one, is shown by its npub over the metadata's emoji.
*/
@Composable
fun WalletsSelector(
modifier: Modifier = Modifier,
globalPrefs: GlobalPrefs,
wallets: Map<WalletId, StoredIdentity>,
walletsMetadata: Map<WalletId, UserWalletMetadata>,
activeWalletId: WalletId?,
canEdit: Boolean,
onWalletClick: (StoredIdentity) -> Unit,
profiles: Map<HexKey, Profile?> = emptyMap(),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
topContent: @Composable (() -> Unit)? = null,
@@ -81,7 +92,7 @@ fun WalletsSelector(
item {
AvailableWalletView(
identity = currentWallet,
globalPrefs = globalPrefs,
profile = profiles[currentWallet.nostrPublicKey],
metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.id),
isCurrent = true,
canEdit = canEdit,
@@ -95,7 +106,7 @@ fun WalletsSelector(
items(items = otherWalletsList) { (walletId, identity) ->
AvailableWalletView(
identity = identity,
globalPrefs = globalPrefs,
profile = profiles[identity.nostrPublicKey],
metadata = walletsMetadata.getByWalletIdOrDefault(walletId),
isCurrent = false,
canEdit = canEdit,
@@ -110,15 +121,23 @@ fun WalletsSelector(
}
/**
* The second line is the npub for both kinds. It used to be the node id, which an
* identity made from a bare key does not have -- and the npub is the identifier the rest
* of the app shows, and the one a user might actually recognise.
* One profile. The name and picture are the nostr profile's -- the widgets the profile
* tab draws itself with -- and the npub is the second line, the identifier the rest of
* the app shows and the one a user might actually recognise. A key the device has no
* kind 0 for yet, one just created or a read-only one never found, is the npub over the
* metadata's emoji. It used to be "Default name" over a random emoji for every row,
* because nothing in this app writes the wallet metadata's name.
*
* What the device holds for the profile is said before the tap -- *wallet* for a seed
* attached, *read only* for a bare public key, nothing for the plain case of a bare
* key -- because it is the difference between a profile that can be signed out of and
* one whose sign-out is a wallet question, and between two profiles with the same name.
*/
@Composable
private fun AvailableWalletView(
modifier: Modifier = Modifier,
globalPrefs: GlobalPrefs,
identity: StoredIdentity,
profile: Profile?,
metadata: UserWalletMetadata,
isCurrent: Boolean,
canEdit: Boolean,
@@ -156,18 +175,33 @@ private fun AvailableWalletView(
.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(MaterialTheme.spacing.space50))
if (profile != null) {
ProfileAvatar(size = AvatarSize, publicKey = identity.nostrPublicKey, profile = profile)
} else {
WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(MaterialTheme.spacing.space50))
}
Spacer(Modifier.width(MaterialTheme.spacing.space150))
Column {
Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(MaterialTheme.spacing.space25))
Text(text = identity.nostrPublicKey.hexToNpubHrp(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp))
if (identity is StoredIdentity.NostrPublic) {
// Said before the tap, not after: two identities can share an avatar
// and a name, and only one of them will let the user send a message.
val npub = identity.nostrPublicKey.hexToNpubHrp()
Text(
text = profile?.humanReadableNameOrPubkey() ?: npub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
if (profile != null) {
Spacer(Modifier.height(MaterialTheme.spacing.space25))
Text(text = npub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp))
}
val held = when (identity) {
is StoredIdentity.Mnemonic -> Res.string.wallet
is StoredIdentity.NostrPublic -> Res.string.read_only
is StoredIdentity.NostrSecret -> null
}
if (held != null) {
Spacer(Modifier.height(MaterialTheme.spacing.space25))
Text(
text = stringResource(Res.string.read_only),
text = stringResource(held),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -182,3 +216,6 @@ private fun AvailableWalletView(
}
}
}
/** A dimension, not a spacing: the picture is the same size the emoji avatar renders at. */
private val AvatarSize = 44.dp

View File

@@ -0,0 +1,95 @@
package press.mantra.compose.ui.view.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import fr.acinq.phoenix.data.ListWalletState
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.StoredIdentity
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.repository.observeProfilesOf
import press.mantra.compose.ui.view.state.ProfilesUIState
/**
* What the switcher shows: the device's profiles, which one is open, and each one's
* nostr profile where the device has it.
*
* Everything comes from flows the app already holds -- the listing, the active
* identity, the wallet metadata -- joined to the profile rows for the listed keys. The
* three states follow `ListWalletState`: `Init` is loading, `Error` is an unreadable
* key file, and `Success` is the list. Nothing here writes; the switch itself is the
* screen's callback into `SovereignWalletViewModel.switchToIdentity`.
*/
class ProfilesViewModel(
listWalletState: StateFlow<ListWalletState>,
availableIdentities: StateFlow<Map<WalletId, StoredIdentity>>,
activeIdentity: StateFlow<Identity?>,
walletsMetadata: Flow<Map<WalletId, UserWalletMetadata>>,
nostrRepository: NostrRepository,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<ProfilesUIState> = combine(
listWalletState,
availableIdentities,
activeIdentity,
walletsMetadata,
) { listState, identities, active, metadata ->
Inputs(listState, identities, active?.id, metadata)
}.flatMapLatest { inputs ->
when (inputs.listState) {
is ListWalletState.Init -> flowOf(ProfilesUIState.Loading)
is ListWalletState.Error -> flowOf(ProfilesUIState.Error)
is ListWalletState.Success -> nostrRepository
.observeProfilesOf(inputs.identities.values.map { it.nostrPublicKey })
.map { profiles ->
ProfilesUIState.Loaded(
identities = inputs.identities,
activeId = inputs.activeId,
walletsMetadata = inputs.metadata,
profiles = profiles,
)
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfilesUIState.Loading)
private data class Inputs(
val listState: ListWalletState,
val identities: Map<WalletId, StoredIdentity>,
val activeId: WalletId?,
val metadata: Map<WalletId, UserWalletMetadata>,
)
companion object {
fun factory(
listWalletState: StateFlow<ListWalletState>,
availableIdentities: StateFlow<Map<WalletId, StoredIdentity>>,
activeIdentity: StateFlow<Identity?>,
walletsMetadata: Flow<Map<WalletId, UserWalletMetadata>>,
nostrRepository: NostrRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ProfilesViewModel(
listWalletState = listWalletState,
availableIdentities = availableIdentities,
activeIdentity = activeIdentity,
walletsMetadata = walletsMetadata,
nostrRepository = nostrRepository,
)
}
}
}
}

View File

@@ -0,0 +1,28 @@
package press.mantra.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import press.mantra.compose.database.model.Profile
import press.mantra.compose.identity.StoredIdentity
/**
* The switcher's states. Three, not four: an empty list cannot happen while something
* is signed in, and the screen does not pretend it can.
*/
sealed interface ProfilesUIState {
/** The list is being read -- in practice never seen, since startup read it, but the state exists and says what it waits for. */
data object Loading : ProfilesUIState
/** One of the two key files could not be read. A retry re-lists. */
data object Error : ProfilesUIState
data class Loaded(
val identities: Map<WalletId, StoredIdentity>,
/** The one that is open, drawn above the divider. */
val activeId: WalletId?,
val walletsMetadata: Map<WalletId, UserWalletMetadata>,
/** The nostr profile of each listed key, where the device has one; the row falls back to the npub. */
val profiles: Map<HexKey, Profile?>,
) : ProfilesUIState
}

View File

@@ -0,0 +1,168 @@
package press.mantra.compose.ui.composable
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runDesktopComposeUiTest
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.phoenix.data.ListWalletState
import fr.acinq.phoenix.data.UserWallet
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.UserPrefs
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import okio.Path.Companion.toPath
import press.mantra.compose.database.model.Profile
import press.mantra.compose.extensions.hexToNpubHrp
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import press.mantra.compose.identity.StoredIdentity
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
import press.mantra.compose.ui.theme.MantraTheme
import press.mantra.compose.ui.view.model.ProfilesViewModel
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The switcher, drawn -- Phase 4 of docs/multiple-profiles.md.
*
* Three profiles: A, with a wallet attached and open; B, a bare key; C, read only. What
* is pinned is what each row says before the tap -- the nostr name where the device has
* one and the npub where it does not, *wallet* and *read only* where they apply and
* nothing for the plain case -- that the open one sits above the others, and that
* tapping another row is exactly one call with its id while tapping the open one is
* none. On the unmerged tree throughout, as `ReadOnlyEntrancesJvmTest` explains.
*/
@OptIn(ExperimentalTestApi::class)
class ProfilesScreenJvmTest {
private val keyA = PrivateKey(ByteVector32("0a".repeat(32)))
private val keyB = PrivateKey(ByteVector32("0b".repeat(32)))
private val keyC = PrivateKey(ByteVector32("0c".repeat(32)))
private val walletId = WalletId("aa".repeat(20))
private val a = StoredIdentity.Mnemonic(UserWallet(walletId, nodeId = "02" + "cd".repeat(32), words = List(12) { "abandon" }), keyA)
private val b = StoredIdentity.nostrSecret(keyB)
private val c = StoredIdentity.nostrPublic(keyC.nostrPublicKeyHex())
private val identities = linkedMapOf(a.id to a, b.id to b, c.id to c)
private fun prefsFile(name: String) =
Files.createTempFile(name, ".preferences_pb").also { it.toFile().delete() }.toString().toPath()
private val open = Identity.signing(
id = a.id,
kind = IdentityKind.Mnemonic,
nostrPrivateKey = keyA,
userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("user") }),
internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("internal") }),
business = null,
)
/** A has a kind 0; B and C do not. */
private val profiles = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow<Profile?> = flowOf(
if (publicKey == a.nostrPublicKey) Profile(publicKey = publicKey, displayName = "Alice", nostrEventId = "e".repeat(64)) else null
)
}
private val metadata = identities.keys.associateWith { id ->
UserWalletMetadata(walletId = id, name = null, avatar = "🦊", createdAt = null, isHidden = false)
}
private val switched = mutableListOf<WalletId>()
private fun viewModel(listState: ListWalletState = ListWalletState.Success) = ProfilesViewModel(
listWalletState = MutableStateFlow(listState),
availableIdentities = MutableStateFlow(identities),
activeIdentity = MutableStateFlow(open),
walletsMetadata = flowOf(metadata),
nostrRepository = profiles,
)
@Composable
private fun Screen(viewModel: ProfilesViewModel, onRetry: () -> Unit = {}) {
MantraTheme {
ProvideSnackbarHost {
ProfilesScreen(
viewModel = viewModel,
onNavigateBack = {},
onSwitch = { switched += it },
onRetry = onRetry,
)
}
}
}
private fun ComposeUiTest.awaitLoaded() = waitUntil(timeoutMillis = 5_000) {
onAllNodesWithText("Alice", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()
}
@Test
fun `each row says what the device holds, and the open one sits above the others`() = runDesktopComposeUiTest(400, 900) {
setContent { Screen(viewModel()) }
awaitLoaded()
// A by its nostr name, with its npub under it and a wallet attached.
onNodeWithText("Alice", useUnmergedTree = true).assertIsDisplayed()
onNodeWithText(a.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Wallet", useUnmergedTree = true).assertIsDisplayed()
// B and C by their npub, since the device has no kind 0 for either; only C carries a label.
onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed()
onNodeWithText(c.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Read only", useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Default name", useUnmergedTree = true).assertDoesNotExist()
val openTop = onNodeWithText("Alice", useUnmergedTree = true).getBoundsInRoot().top
val otherTop = onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).getBoundsInRoot().top
assertTrue(openTop < otherTop, "the open profile is drawn above the others")
}
@Test
fun `tapping another profile switches to it once, and tapping the open one does nothing`() = runDesktopComposeUiTest(400, 900) {
setContent { Screen(viewModel()) }
awaitLoaded()
onNodeWithText("Alice", useUnmergedTree = true).performClick()
assertEquals(emptyList(), switched)
onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).performClick()
assertEquals(listOf(b.id), switched)
}
@Test
fun `an unreadable key file is an error with a retry`() = runDesktopComposeUiTest(400, 900) {
var retried = 0
setContent { Screen(viewModel(ListWalletState.Error.Serialization), onRetry = { retried++ }) }
waitUntil(timeoutMillis = 5_000) {
onAllNodesWithText("Try again", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()
}
onNodeWithText("Try again", useUnmergedTree = true).performClick()
assertEquals(1, retried)
}
@Test
fun `the list is a pushed screen, with a way back`() = runDesktopComposeUiTest(400, 900) {
setContent { Screen(viewModel()) }
awaitLoaded()
onNodeWithText("Profiles", useUnmergedTree = true).assertIsDisplayed()
onNodeWithContentDescription("Back", useUnmergedTree = true).assertIsDisplayed()
}
}

View File

@@ -101,6 +101,7 @@ class ReadOnlyEntrancesJvmTest {
onNodeWithText("Key recovery", useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Key package management", useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed()
}
@Test
@@ -110,6 +111,8 @@ class ReadOnlyEntrancesJvmTest {
onNodeWithText("Key recovery", useUnmergedTree = true).assertDoesNotExist()
onNodeWithText("Key package management", useUnmergedTree = true).assertDoesNotExist()
onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed()
// A read-only identity can leave for another profile the same way a signing one can.
onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed()
}
// --- Profile: sign out is real for a read-only identity, and confirms first ---