feat(ui): leaving one profile among several

Phase 7 of docs/multiple-profiles.md. Small, because the forget sequence was
built right.

Sign out of a read-only identity and "forget this key" for a bare key both
run ForgetIdentity and then the tail -- re-list, clear the default since
Phase 3, show the selector with what remains or Landing if nothing does --
and that is the right behaviour with several profiles as well; nothing in
the sequence changes. A profile with a wallet attached answers
WalletAttached from Phase 1 and is offered neither exit: its sign-out row
stays the pending route, for the reason both sign-in plans gave, and it is
now also the only way such a profile can leave, since the repair would
write its credential back. What changes is that the user is no longer stuck
behind it -- "switch profile" is the row above.

The not-found screen learns about the others. It offers "try again" and,
for a read-only identity, "use a different key", which forgets it; with
another profile on the device it now offers "switch profile" as well, for
every kind, pushing the switcher -- which has a back button, so the screen
is still there if the user changes their mind. The identity that was not
found stays listed; forgetting it remains a separate decision. The nav host
passes the callback only when the listing holds more than one.

Tests: ReadOnlyEntrancesJvmTest -- with another profile on the device the
not-found state offers the switch beside the read-only exit and it calls
through once; a signing identity that was not found gets it above the
set-up form; with one profile there is nothing to switch to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@9bb340042d
This commit is contained in:
Kgothatso Ngako
2026-09-13 01:44:44 +02:00
parent b92659fe92
commit 8edb52fff3
3 changed files with 61 additions and 1 deletions

View File

@@ -43,6 +43,7 @@ import mantra.composeapp.generated.resources.set_up_a_profile
import mantra.composeapp.generated.resources.try_again
import mantra.composeapp.generated.resources.use_a_different_key
import mantra.composeapp.generated.resources.use_a_different_key_question
import mantra.composeapp.generated.resources.switch_profile
import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again
import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog
import press.mantra.compose.ui.composable.widgets.rememberNotifier
@@ -82,6 +83,10 @@ fun UnsyncedProfileScreen(
// act as signing out, reached from the other end of the identity's life. Null where
// there is no identity to leave (previews), in which case that kind has no exit here.
signOut: SignOutDependencies? = null,
// The third exit, for every kind: another profile on the device to go to instead.
// Null when there is none, in which case the row is not drawn. The identity that was
// not found stays listed; forgetting it remains a separate decision.
onSwitchProfile: (() -> Unit)? = null,
) {
val viewModel: UnsyncedProfileViewModel = viewModel(
factory = UnsyncedProfileViewModel.factory(
@@ -132,6 +137,7 @@ fun UnsyncedProfileScreen(
viewModel = viewModel,
unsignedNostrEventId = state.unsignedNostrEventId,
onUseADifferentKey = signOutViewModel?.let { { it.askToSignOut() } },
onSwitchProfile = onSwitchProfile,
)
}
}
@@ -190,6 +196,8 @@ private fun NotFound(
unsignedNostrEventId: Long,
/** Shown only where the set-up form is not; null when there is no identity to leave. */
onUseADifferentKey: (() -> Unit)?,
/** Shown for every kind, when there is another profile to go to. */
onSwitchProfile: (() -> Unit)?,
) {
val nameField = viewModel.formState.nameField.textFieldState
val biographyField = viewModel.formState.biographyField.textFieldState
@@ -222,6 +230,19 @@ private fun NotFound(
style = MaterialTheme.typography.bodyMedium,
)
// With other profiles on the device, a way to one of them -- for every kind, since
// the wait is the same whichever kind is waiting. See docs/multiple-profiles.md,
// Phase 7.
if (onSwitchProfile != null) {
TextButton(
modifier = Modifier.align(Alignment.End),
enabled = !isActionPending,
onClick = onSwitchProfile,
) {
Text(text = stringResource(Res.string.switch_profile))
}
}
// Setting a profile up is signing a kind 0, which a read-only identity cannot do.
// The form is not offered to it -- and then this state would have no exit: the
// profile tab is not reachable before ProfileLoaded, so a user whose npub was not

View File

@@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -757,10 +758,16 @@ fun MantraNavHost(
}
composable<UnsyncedProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<UnsyncedProfileRoute>()
val identities by sovereignWalletViewModel.availableIdentities.collectAsState()
UnsyncedProfileScreen(
unsyncedProfilePublicKey = route.publicKey,
nostrRepository = databaseNostrRepository,
signOut = signOutOfReadOnlyIdentity,
// Another profile to go to, when there is one; the switcher has a back
// button, so this screen is still there if the user changes their mind.
onSwitchProfile = if (identities.size > 1) {
{ navController.navigate(route = ProfilesRoute) }
} else null,
)
}
composable<UnqueuedProfileSynchronizationRoute> { backStackEntry ->

View File

@@ -45,6 +45,7 @@ import press.mantra.compose.ui.theme.MantraTheme
import press.mantra.compose.ui.view.state.ActiveProfileUIState
import press.mantra.compose.ui.view.state.HomeScreenUIState
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The inventory in Phase 5 of docs/npub-sign-in.md, as assertions.
@@ -154,6 +155,36 @@ class ReadOnlyEntrancesJvmTest {
onNodeWithText("Use a different key?", useUnmergedTree = true).assertIsDisplayed()
}
// --- Not found: the third exit, when there is another profile to go to ---
@Test
fun `with another profile on the device the not-found state offers to switch, for either kind`() = runDesktopComposeUiTest(400, 1200) {
var switched = 0
setContent { AsIdentity(canSign = false) { NotFound(onSwitchProfile = { switched++ }) } }
onNodeWithText("Switch profile", useUnmergedTree = true).performClick()
assertEquals(1, switched)
// The read-only exit is still there beside it; switching does not forget.
onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed()
}
@Test
fun `a signing identity that was not found is offered the switch above the set-up form`() = runDesktopComposeUiTest(400, 1200) {
setContent { AsIdentity(canSign = true) { NotFound(onSwitchProfile = {}) } }
onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed()
// The heading and the button both say it; either is enough to know the form is there.
onAllNodesWithText("Set up a profile", useUnmergedTree = true).onFirst().assertIsDisplayed()
}
@Test
fun `with one profile on the device there is nothing to switch to`() = runDesktopComposeUiTest(400, 1200) {
setContent { AsIdentity(canSign = false) { NotFound(onSwitchProfile = null) } }
onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Switch profile", useUnmergedTree = true).assertDoesNotExist()
}
// --- harness ---
private val readOnlyIdentity = MutableStateFlow<Identity?>(
@@ -174,11 +205,12 @@ class ReadOnlyEntrancesJvmTest {
)
@Composable
private fun NotFound() {
private fun NotFound(onSwitchProfile: (() -> Unit)? = null) {
UnsyncedProfileScreen(
unsyncedProfilePublicKey = publicKey,
nostrRepository = NotFoundAccount,
signOut = signOutDependencies,
onSwitchProfile = onSwitchProfile,
)
}