refactor(ui): one extended FAB for every screen's primary action, with a gap in it

Twenty screens end their bottom bar in an `ExtendedFloatingActionButton` --
twenty-one buttons, one screen having two -- and eighteen of those were built
from the content-lambda overload with an `Icon` directly followed by a `Text`.
That overload puts nothing between the two, so the icon touched the label on
every one of them: visible in any render, and unnoticed because every FAB in
the app looked the same. Thirteen of the eighteen also carried the same
twenty-line block for looking disabled, because M3 gives a FAB no `enabled`.

**`ExtendedFab`, once, in widgets/buttons.** A label, an icon slot, `enabled`
and `onClick`. It draws `space150` between icon and label, which is the 12dp
M3 names `ExtendedFabEndIconPadding`; it borrows the disabled colours every
other button in the app uses, marks the node disabled so a screen reader does
not announce a button it is happy to press, and drops the press -- so the
thirteen call sites lose their `if (!canX) return@…` guards along with the
block and the `buttonColors` each of them declared. An icon slot rather than
an image, because two screens put a progress indicator where the icon was
while the action they started is out, and they still do.

**Not M3's `text`/`icon` overload, which would have spaced them for free.**
Its source wraps the label in `clearAndSetSemantics {}`: the label leaves the
merged semantics tree, a screen reader is told only what the icon says, and a
test cannot find the button by what it says -- five screen tests do. The
three FABs that were on that overload move to the widget too, and two of them,
Home's "New chat" and "Start the key ceremony", had a decorative icon beside
the cleared label and so no accessible name at all until now.
`ReadOnlyEntrancesJvmTest` had gone to the unmerged tree to find "New chat"
for this reason; the habit is harmless and stays. Every icon's description is
as it was, since several tests reach their FAB by it.

`ExtendedFabJvmTest` pins the decision: found by its label in the merged tree,
the icon before the label with a gap between, and disabled both announced and
refusing the press. The two docs that recorded the old state -- the
conformance account's "left alone" and the npub plan's unmerged-tree note --
each say what superseded them.

Verified with :composeApp:m3Audit (all budgets met, 0 dp literals in spacing
positions), :composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest
(1088 tests, 3 new).

Replayed onto Mantra by docs/curated-to-mantra.md: AcceptCuratedSuggestionScreen.kt: deleted, as this commit deletes it upstream; AddGroupPostScreen.kt: deleted, as this commit deletes it upstream; EditGroupCuratedSchemaScreen.kt: deleted, as this commit deletes it upstream; EditGroupNostrProfileScreen.kt: deleted, as this commit deletes it upstream; EditGroupRelaysScreen.kt: deleted, as this commit deletes it upstream; GroupCuratedEntriesScreen.kt: deleted, as this commit deletes it upstream; ProposeGroupEventScreen.kt: deleted, as this commit deletes it upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@ca6c16beb0
This commit is contained in:
Kgothatso Ngako
2026-09-13 13:31:47 +02:00
parent b3379f8117
commit 883bf1c84c
17 changed files with 306 additions and 313 deletions

View File

@@ -21,12 +21,9 @@ import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -36,7 +33,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -46,8 +42,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -87,6 +81,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -148,10 +143,6 @@ fun AddArtifactScreen(
// about afterwards, and the button says so.
val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
@@ -169,28 +160,11 @@ fun AddArtifactScreen(
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canAddArtifact) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (canAddArtifact) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canAddArtifact) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
ExtendedFab(
label = stringResource(Res.string.propose_artifact),
icon = { Icon(Icons.Default.Add, contentDescription = "Propose artifact") },
enabled = canAddArtifact,
onClick = {
if (!canAddArtifact) return@ExtendedFloatingActionButton
addArtifactViewModel.addArtifact(
localChatRoom = addArtifactUIState.localChatRoom,
nameField = nameFieldState,
@@ -220,13 +194,7 @@ fun AddArtifactScreen(
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose artifact"
)
Text(stringResource(Res.string.propose_artifact))
}
)
}
)
}

View File

@@ -13,10 +13,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -24,13 +21,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -66,6 +60,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
@OptIn(ExperimentalMaterial3Api::class)
@@ -133,10 +128,6 @@ fun AddChapterScreen(
paragraphCount > 0 &&
!tooManyChunks
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
@@ -152,27 +143,12 @@ fun AddChapterScreen(
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeChapter) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeChapter) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeChapter) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
ExtendedFab(
label = stringResource(Res.string.propose_chapter),
icon = { Icon(Icons.Default.Add, contentDescription = "Propose chapter") },
enabled = canProposeChapter,
onClick = {
if (artifactVersion == null || !canProposeChapter) {
return@ExtendedFloatingActionButton
}
if (artifactVersion == null) return@ExtendedFab
addChapterViewModel.addChapter(
localChatRoom = addChapterUIState.localChatRoom,
@@ -200,13 +176,7 @@ fun AddChapterScreen(
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose chapter"
)
Text(stringResource(Res.string.propose_chapter))
}
)
}
)
}

View File

@@ -17,11 +17,8 @@ import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -30,14 +27,11 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -71,6 +65,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@@ -119,10 +114,6 @@ fun AddDialectScreen(
val countryFieldState = rememberTextFieldState()
val languageFieldState = rememberTextFieldState()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
@@ -140,26 +131,11 @@ fun AddDialectScreen(
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (addDialectUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (addDialectUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (addDialectUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
ExtendedFab(
label = stringResource(Res.string.propose_dialect),
icon = { Icon(Icons.Default.Add, contentDescription = "Propose dialect") },
enabled = addDialectUIState.canSign,
onClick = {
if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton
addDialectViewModel.addDialect(
localChatRoom = addDialectUIState.localChatRoom,
nameField = nameFieldState,
@@ -186,13 +162,7 @@ fun AddDialectScreen(
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose dialect"
)
Text(stringResource(Res.string.propose_dialect))
}
)
}
)
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -52,6 +51,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@@ -109,7 +109,12 @@ fun AddMemberToChatRoomConfirmationScreen(
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
ExtendedFab(
label = stringResource(
Res.string.invite_2,
addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()
),
icon = { Icon(Icons.Default.Add, contentDescription = "Invite new member") },
onClick = {
addMemberToChatRoomConfirmationViewModel.inviteToChatRoom(
localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom,
@@ -118,14 +123,7 @@ fun AddMemberToChatRoomConfirmationScreen(
onInviteSent = onInviteSent
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Invite new member"
)
Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()))
}
)
},
actions = {

View File

@@ -12,18 +12,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,8 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -67,6 +61,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -135,10 +130,6 @@ fun AddTranslationArtifactVersionScreen(
selectedDialect != null &&
addTranslationUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
@@ -151,27 +142,12 @@ fun AddTranslationArtifactVersionScreen(
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeTranslation) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeTranslation) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeTranslation) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
ExtendedFab(
label = stringResource(Res.string.propose_translation),
icon = { Icon(Icons.Default.Translate, contentDescription = "Propose translation") },
enabled = canProposeTranslation,
onClick = {
if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) {
return@ExtendedFloatingActionButton
}
if (artifactVersion == null || selectedDialect == null) return@ExtendedFab
addTranslationArtifactVersionViewModel.addTranslation(
localChatRoom = addTranslationUIState.localChatRoom,
@@ -201,13 +177,7 @@ fun AddTranslationArtifactVersionScreen(
}
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Propose translation"
)
Text(stringResource(Res.string.propose_translation))
}
)
}
)
}

View File

@@ -24,12 +24,9 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
@@ -41,7 +38,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
@@ -101,6 +97,7 @@ import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel
import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome
import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
import press.mantra.compose.extensions.toFormattedTimeAndDateString
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlin.time.Instant
@@ -193,9 +190,6 @@ fun BroadcastGroupSignedEventScreen(
val json = signedEvent.toEvent().toJson()
val summary = ProposedEvent.summarize(signedEvent.toEvent())
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
val canSend = !isPending
Scaffold(
@@ -213,26 +207,11 @@ fun BroadcastGroupSignedEventScreen(
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canSend) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canSend) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canSend) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
ExtendedFab(
label = stringResource(Res.string.broadcast),
icon = { Icon(Icons.Default.CellTower, contentDescription = "Broadcast to these relays") },
enabled = canSend,
onClick = {
if (!canSend) return@ExtendedFloatingActionButton
broadcastGroupSignedEventViewModel.broadcast(
signedEvent = signedEvent,
// Stays on the screen: an empty list is
@@ -240,13 +219,7 @@ fun BroadcastGroupSignedEventScreen(
onNoRelays = { notify(noRelays) }
)
}
) {
Icon(
Icons.Default.CellTower,
contentDescription = "Broadcast to these relays"
)
Text(stringResource(Res.string.broadcast))
}
)
}
)
}

View File

@@ -28,7 +28,6 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
@@ -117,6 +116,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
/**
@@ -194,7 +194,13 @@ fun DkgRitualScreen(
if (pending != null) {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
ExtendedFab(
label = when (pending) {
DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join)
DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute)
DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm)
},
icon = { Icon(Icons.Default.Key, contentDescription = Decorative) },
onClick = {
onNavigateToRoute(
when (pending) {
@@ -216,16 +222,7 @@ fun DkgRitualScreen(
}
)
}
) {
Icon(Icons.Default.Key, contentDescription = Decorative)
Text(
text = when (pending) {
DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join)
DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute)
DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm)
}
)
}
)
},
actions = {}
)
@@ -238,19 +235,21 @@ fun DkgRitualScreen(
if (canStartRitual) {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { dkgRitualViewModel.startRitual() }
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
ExtendedFab(
label = if (session == null) {
stringResource(Res.string.start_key_ceremony)
} else {
Icon(Icons.Default.Key, contentDescription = "Start")
}
Text(
text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again)
)
}
stringResource(Res.string.try_again)
},
icon = {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.Key, contentDescription = "Start")
}
},
onClick = { dkgRitualViewModel.startRitual() }
)
},
actions = {
Text(

View File

@@ -17,7 +17,6 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
@@ -81,6 +80,7 @@ import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_p
import mantra.composeapp.generated.resources.sign_in_with_the_nsec
import press.mantra.compose.ui.composable.widgets.LocalCanSign
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -456,8 +456,8 @@ private fun ReadOnlyInbox(onSignInWithTheNsec: () -> Unit) {
*/
@Composable
private fun NewChatButton(onClick: () -> Unit) {
ExtendedFloatingActionButton(
onClick = onClick,
ExtendedFab(
label = stringResource(Res.string.new_chat),
icon = {
Icon(
Icons.Default.Add,
@@ -465,7 +465,7 @@ private fun NewChatButton(onClick: () -> Unit) {
contentDescription = Decorative,
)
},
text = { Text(stringResource(Res.string.new_chat)) },
onClick = onClick,
)
}

View File

@@ -16,7 +16,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@@ -57,6 +56,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
/**
@@ -123,26 +123,21 @@ fun SelectChatRoomMembersScreen(
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
ExtendedFab(
label = if (selectedCount > 0) {
stringResource(Res.string.next_with, selectedCount)
} else {
stringResource(Res.string.next)
},
icon = {
Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next")
},
onClick = {
selectChatRoomMembersViewModel.selectChatRoomType(
onNavigateToRoute = onNavigateToRoute
)
}
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next"
)
Text(
text = if (selectedCount > 0) {
stringResource(Res.string.next_with, selectedCount)
} else {
stringResource(Res.string.next)
}
)
}
)
},
actions = {
Text(

View File

@@ -23,7 +23,6 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -74,6 +73,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
/**
@@ -151,32 +151,25 @@ fun SelectChatRoomTypeScreen(
bottomBar = {
BottomAppBar(
floatingActionButton = {
ExtendedFloatingActionButton(
ExtendedFab(
label = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) {
stringResource(Res.string.open_chat)
} else {
stringResource(Res.string.create_chat)
},
icon = {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.Check, contentDescription = "Create chat")
}
},
onClick = {
selectChatRoomTypeViewModel.createChatRoom(
onNavigateToRoute = onNavigateToRoute
)
}
) {
if (isActionPending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp)
)
} else {
Icon(
Icons.Default.Check,
contentDescription = "Create chat"
)
}
Text(
text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) {
stringResource(Res.string.open_chat)
} else {
stringResource(Res.string.create_chat)
}
)
}
)
},
actions = {
Text(

View File

@@ -16,7 +16,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -61,6 +60,7 @@ 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.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
@@ -123,7 +123,9 @@ fun SelectSubgroupAdminsScreen(
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
ExtendedFab(
label = stringResource(Res.string.start_the_key_ceremony),
icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) },
onClick = {
scope.launch {
// The refusal is recomputed rather than cached on
@@ -137,9 +139,7 @@ fun SelectSubgroupAdminsScreen(
viewModel.confirm(onNavigateToRoute)
}
}
},
icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) },
text = { Text(stringResource(Res.string.start_the_key_ceremony)) }
}
)
}
) { innerPadding ->

View File

@@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -33,6 +32,7 @@ import mantra.composeapp.generated.resources.view_invites
import mantra.composeapp.generated.resources.who_will_you_be_passing_the_aux_to
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.ConformancePreviews
@@ -51,17 +51,9 @@ fun SocialPreconditionScreen(
},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = Modifier,
icon = {
Icon(
Icons.Default.SkipNext,
contentDescription = "Skip for now"
)
},
text = {
Text(stringResource(Res.string.skip_for_now))
},
ExtendedFab(
label = stringResource(Res.string.skip_for_now),
icon = { Icon(Icons.Default.SkipNext, contentDescription = "Skip for now") },
onClick = {
onNavigateToSkipForNow.invoke()
}

View File

@@ -14,11 +14,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -26,13 +23,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -64,6 +58,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
import press.mantra.compose.ui.theme.ConformancePreviews
@OptIn(ExperimentalMaterial3Api::class)
@@ -111,10 +106,6 @@ fun TranslateChunkScreen(
is TranslateChunkUIState.Loaded -> {
val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText)
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(
@@ -130,28 +121,11 @@ fun TranslateChunkScreen(
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (translateChunkUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (translateChunkUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (translateChunkUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
ExtendedFab(
label = stringResource(Res.string.propose_translation),
icon = { Icon(Icons.Default.Add, contentDescription = "Propose translation") },
enabled = translateChunkUIState.canSign,
onClick = {
if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton
translateChunkViewModel.proposeTranslation(
localChatRoom = translateChunkUIState.localChatRoom,
originalChunk = translateChunkUIState.originalChunk,
@@ -178,13 +152,7 @@ fun TranslateChunkScreen(
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose translation"
)
Text(stringResource(Res.string.propose_translation))
}
)
}
)
}

View File

@@ -0,0 +1,80 @@
package press.mantra.compose.ui.composable.widgets.buttons
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import press.mantra.compose.ui.theme.spacing
/**
* A screen's one primary action, as an extended FAB with a label beside its icon.
*
* Every bottom bar in the app ends in one of these -- propose, broadcast, next,
* create -- and this is the one place their shape is decided. Two decisions:
*
* - **The gap between icon and label is drawn here.** M3's `ExtendedFloatingActionButton`
* comes in two overloads. The `text`/`icon` one spaces them 12dp apart, but wraps the
* label in `clearAndSetSemantics`, so a screen reader is told only what the icon says
* and a test cannot find the button by its label; the content-lambda one keeps the
* label in the tree and puts nothing between the two. This uses the second and adds
* the first's gap, `space150`, which is the 12dp M3 names `ExtendedFabEndIconPadding`.
* The label is what the button says, so it has to be what the button *announces*.
* - **A FAB has no `enabled`, so unavailable is a look and a refusal, not a state.**
* M3 gives the FAB no such parameter. Here [enabled] false borrows the disabled
* colours every other button in the app uses rather than inventing a shade, marks the
* node disabled so a screen reader does not announce a button it is happy to press,
* and drops the press -- looking unavailable is not being unavailable, and each of
* those three has to be done for the look to be honest.
*
* @param label what the button says. Text, so from the catalogue; drawn after the icon.
* @param icon what sits before the label. Usually an `Icon` -- whose description is the
* caller's decision, `Decorative` when the label says it all and a real one when a test
* reaches for the button by it -- and sometimes a progress indicator in its place while
* the action it started is out.
* @param enabled whether a press does anything. False also changes the look, as above.
*/
@Composable
fun ExtendedFab(
label: String,
icon: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val buttonColors = ButtonDefaults.buttonColors()
ExtendedFloatingActionButton(
modifier = if (enabled) {
modifier
} else {
modifier.semantics { disabled() }
},
containerColor = if (enabled) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (enabled) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (enabled) onClick()
}
) {
icon()
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space150))
Text(label)
}
}