diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 69c81a63..bb9c4025 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -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 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 1ee9ae8e..dfd6fda4 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 @@ -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 { backStackEntry -> val route = backStackEntry.toRoute() + 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 { backStackEntry -> 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 eb85a62f..5239ebb5 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 @@ -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( @@ -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, ) }