diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index afcbc220..2eeb8f7b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -42,7 +42,6 @@ BIP39 seed with the standard BIP84 derivation path. The profile\'s nostr key comes off the same seed, so these 12 words restore both. Block user Cancel - Change account Chapter detail Chapter name Chapter translation @@ -478,4 +477,8 @@ Preparing profiles… Starting wallet… Unlock to continue + + Loading profiles… + Switch profile + Wallet diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt new file mode 100644 index 00000000..990ac668 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt @@ -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): Flow> { + 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() } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index f467485c..4b67bc7d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -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) ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt new file mode 100644 index 00000000..cd0805cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt @@ -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, + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index a0933d79..0d80ba68 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 868e5c46..6d05f453 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -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 { + 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 { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt new file mode 100644 index 00000000..eacf0902 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt new file mode 100644 index 00000000..e0a10aee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt @@ -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, nostrRepository: NostrRepository): State> { + val keys = publicKeys.toSet() + return produceState>(initialValue = emptyMap(), keys, nostrRepository) { + nostrRepository.observeProfilesOf(keys).collect { value = it } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index b273d2d1..d0fabe47 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -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, walletsMetadata: Map, activeWalletId: WalletId?, canEdit: Boolean, onWalletClick: (StoredIdentity) -> Unit, + profiles: Map = 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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt new file mode 100644 index 00000000..b84e62e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt @@ -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, + availableIdentities: StateFlow>, + activeIdentity: StateFlow, + walletsMetadata: Flow>, + nostrRepository: NostrRepository, +) : ViewModel() { + + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = 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, + val activeId: WalletId?, + val metadata: Map, + ) + + companion object { + fun factory( + listWalletState: StateFlow, + availableIdentities: StateFlow>, + activeIdentity: StateFlow, + walletsMetadata: Flow>, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProfilesViewModel( + listWalletState = listWalletState, + availableIdentities = availableIdentities, + activeIdentity = activeIdentity, + walletsMetadata = walletsMetadata, + nostrRepository = nostrRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt new file mode 100644 index 00000000..f680b2f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt @@ -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, + /** The one that is open, drawn above the divider. */ + val activeId: WalletId?, + val walletsMetadata: Map, + /** The nostr profile of each listed key, where the device has one; the row falls back to the npub. */ + val profiles: Map, + ) : ProfilesUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt new file mode 100644 index 00000000..c855cc83 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt @@ -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 = 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() + + 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() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt index 2dfc62ac..eb85a62f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -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 ---