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 <noreply@anthropic.com>
Pulled-From: curated/curated@80735df86a
This commit is contained in:
Kgothatso Ngako
2026-09-13 13:43:20 +02:00
parent 883bf1c84c
commit e66c174b4f
2 changed files with 97 additions and 79 deletions

View File

@@ -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))

View File

@@ -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