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:
@@ -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>
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user