From e66c174b4f82f60d79281c3c97f6676bdd8af64f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 13:43:20 +0200 Subject: [PATCH] feat(ui): the preview's action is the app's extended FAB curated gained ExtendedFab while the preview was being built: every screen's bottom-bar action, with an enabled that borrows the disabled colours, marks the node disabled and drops the press. The preview had chosen a Button for exactly the reason that widget removes -- a FAB had no disabled state -- so after the merge it was the one screen whose primary action was shaped somewhere else. Now it is that FAB in a bottom bar, the indicator in its icon slot while the key package is still owed, and the reason line stays in the body above it. Hidden, bar and all, for a read-only identity. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@80735df86ada10c65e044b1e8bbbcf8c8655b4db --- .../ui/composable/ProfilePreviewScreen.kt | 172 ++++++++++-------- docs/npub-profile-preview.md | 4 +- 2 files changed, 97 insertions(+), 79 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt index 27d0cc46..ea5d41da 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt @@ -2,18 +2,15 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mail import androidx.compose.material.icons.filled.PersonSearch -import androidx.compose.material3.Button +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator @@ -28,7 +25,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -58,6 +54,7 @@ import press.mantra.compose.ui.composable.widgets.LocalCanSign 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.buttons.ExtendedFab import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme @@ -76,9 +73,13 @@ import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness * where it has always been made, and this screen is popped under it, as the profile * detail is under a chat started from it. * - * The button reads the state's readiness: a chat that exists opens, one that can be - * started starts, and one that cannot yet says why under a disabled button rather - * than twenty seconds into the next screen. See docs/npub-profile-preview.md. + * The action is the app's extended FAB, in the bottom bar where every screen's + * primary action goes, and it reads the state's readiness: a chat that exists opens, + * one that can be started starts, and one that cannot yet is disabled with the + * reason in the body above it rather than twenty seconds into the next screen. Hidden + * altogether, bar and all, for a read-only identity: a message is a gift wrap sealed + * with the sender's key, and there is nothing such an identity can do here but look. + * See docs/npub-profile-preview.md. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -105,6 +106,8 @@ fun ProfilePreviewScreen( // pasted, and the block is not a confirmation without them. val npub = remember(profilePublicKey) { profilePublicKey.hexToNpubHrp() } + val canSign = LocalCanSign.current + Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { @@ -113,8 +116,42 @@ fun ProfilePreviewScreen( navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, + bottomBar = { + // Read straight off the view model rather than inside the transition below: + // the bar is a slot of the scaffold, not of the content, and it exists only + // once there is a person to act on. + val state = profilePreviewViewModel.profilePreviewUIState + if (canSign && state is ProfilePreviewUIState.Loaded) { + BottomAppBar( + floatingActionButton = { + ChatAction( + readiness = state.readiness, + onOpenChat = { chatRoomId, relayHint -> + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + ) + ) + }, + onStartChat = { + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ) + ) + }, + ) + }, + actions = {}, + ) + } + }, ) { innerPadding -> - // The bar is constant, so the transition wraps the content alone. None of the + // The bars are constant, so the transition wraps the content alone. None of the // branches needs `ColumnScope`, which is the one thing that costs. ScreenStateTransition(profilePreviewViewModel.profilePreviewUIState) { uiState -> when (val state = uiState) { @@ -141,30 +178,12 @@ fun ProfilePreviewScreen( ) { ProfileBlock(profile = state.profile, publicKey = profilePublicKey, npub = npub) - // A message is a gift wrap, sealed with the sender's key. Not for a - // read-only identity, which is shown the person and nothing to do. - if (LocalCanSign.current) { - ChatAction( + // Why the button in the bar below is or is not pressable. Nothing + // for a read-only identity: there is no button to explain. + if (canSign) { + ReadinessLine( name = state.profile.humanReadableNameOrPubkey(), readiness = state.readiness, - onOpenChat = { chatRoomId, relayHint -> - onStartChat( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - ) - ) - }, - onStartChat = { - onStartChat( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = profilePublicKey, - relayHint = null, - ) - ) - }, onRetry = { profilePreviewViewModel.retry() }, ) } @@ -266,62 +285,59 @@ private fun Npub(npub: String) { /** * The one thing to do about the person, and whether it can be done yet. * - * A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite - * confirmation uses, because a FAB has no disabled state in M3 or in the API, and a - * disabled button with the reason under it is what the action needs while the relays - * are still answering: the action exists and is unavailable, which is what M3's - * disabled state is for, and a screen that lost its only button would read as broken. + * The app's extended FAB, so that this screen's primary action is shaped where every + * other screen's is. Its `enabled` is what the readiness decides, and while the key + * package is still owed the icon's slot holds the indicator, as the two screens with an + * action out on the relays do. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ChatAction( - name: String, readiness: Readiness, onOpenChat: (chatRoomId: String, relayHint: String?) -> Unit, onStartChat: () -> Unit, +) { + ExtendedFab( + label = stringResource( + if (readiness is Readiness.ExistingChat) Res.string.open_chat else Res.string.start_new_chat + ), + icon = { + if (readiness == Readiness.Checking) { + LoadingIndicator() + } else { + Icon( + Icons.Default.Mail, + // The label is beside it. + contentDescription = Decorative, + ) + } + }, + enabled = readiness is Readiness.ExistingChat || readiness == Readiness.CanStartChat, + onClick = { + when (readiness) { + is Readiness.ExistingChat -> onOpenChat(readiness.chatRoomId, readiness.relayHint) + else -> onStartChat() + } + }, + ) +} + +/** + * The reason the button below is or is not pressable: a supporting line, not a second + * message, and nothing at all when the answer is simply yes. + */ +@Composable +private fun ReadinessLine( + name: String, + readiness: Readiness, onRetry: () -> Unit, ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), - ) { - Button( - modifier = Modifier.fillMaxWidth(), - enabled = readiness is Readiness.ExistingChat || readiness == Readiness.CanStartChat, - onClick = { - when (readiness) { - is Readiness.ExistingChat -> onOpenChat(readiness.chatRoomId, readiness.relayHint) - else -> onStartChat() - } - }, - ) { - Icon( - Icons.Default.Mail, - // The label is beside it. - contentDescription = Decorative, - ) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) - Text( - stringResource( - if (readiness is Readiness.ExistingChat) Res.string.open_chat else Res.string.start_new_chat - ) - ) - } - - // A supporting line, not a second message. - when (readiness) { - is Readiness.ExistingChat -> SupportingLine(stringResource(Res.string.you_already_have_a_chat_with_s, name)) - Readiness.CanStartChat -> Unit - Readiness.Checking -> { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), - ) { - LoadingIndicator() - SupportingLine(stringResource(Res.string.checking_whether_s_can_receive_messages_here, name)) - } - } - Readiness.NotYetOnMantra -> { + when (readiness) { + is Readiness.ExistingChat -> SupportingLine(stringResource(Res.string.you_already_have_a_chat_with_s, name)) + Readiness.CanStartChat -> Unit + Readiness.Checking -> SupportingLine(stringResource(Res.string.checking_whether_s_can_receive_messages_here, name)) + Readiness.NotYetOnMantra -> { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { SupportingLine(stringResource(Res.string.s_hasn_t_set_up_messaging_on_mantra_yet, name)) TextButton(onClick = onRetry) { Text(stringResource(Res.string.try_again)) diff --git a/docs/npub-profile-preview.md b/docs/npub-profile-preview.md index 848c417b..77ab69b5 100644 --- a/docs/npub-profile-preview.md +++ b/docs/npub-profile-preview.md @@ -26,6 +26,7 @@ reasoning; where the implementation chose differently the table below says so: | `initiate()` "runs from a `LaunchedEffect`" | and is idempotent besides: a recomposition that calls it again opens no second collect, which the view-model test asserts | | a screenshot pass under `docs/material-design-conformance.md` | five PNGs from a throwaway `jvmTest` with `captureToImage`, looked at and deleted — the found, not-found, checking, not-yet-on-Mantra and existing-chat states, at a phone's width | | Phase 2's screen with one repository | Phase 4 adds `chatRepository` to the screen, its host entry and its tests, as the plan said it would; the Phase 2 commit is the screen without it | +| a `Button` under the block, "because a FAB has no disabled state in M3 or in the API" | **superseded on the way in**: `curated` gained `ExtendedFab` while this was built — every screen's bottom-bar action, with an `enabled` that borrows the disabled colours, marks the node disabled and drops the press — so the reason for the `Button` was gone before the branch merged. The action is that FAB in a `BottomAppBar`, the indicator in its icon slot while the key package is owed, and the reason line stays in the body above it | One thing found on the way that is in no phase: the dialog's `Dialog` renders inside the desktop test root, so `onNodeWithText` finds its field and its button without a @@ -404,7 +405,8 @@ Button(onClick = { A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite confirmation uses, because Phase 4 will disable it with a reason beside it, and a -FAB has no disabled state in M3 or in the API. +FAB has no disabled state in M3 or in the API. (Superseded before the merge — see the +table at the top.) A read-only identity sees the block and no button. The screen is not reachable by one today — the sheet is behind `canSign` — but the route will be reachable from a