feat: draw the expressive loading indicator, and stop shouting the sign-out button

Phase 5, second step, of docs/material-design-conformance.md. Two smaller pieces, and a
correction to the plan.

**41 loading states stopped being a gold spinner.** `LoadingDataIndicator` wraps every wait
in the app, and it drew a `CircularProgressIndicator` hardcoded to 80dp in
`colorScheme.secondary` -- the brand gold, which reads as a warning rather than as a wait,
on a component that has a size of its own. It now draws `LoadingIndicator`, which is M3's
component for an indeterminate wait with no progress to report and the one
`MaterialExpressiveTheme` expects to be paired with. One wrapper changed; 41 call sites
follow.

**The profile screen had two maximum-emphasis buttons, and one of them was Sign out.**
Seven actions in one list: five `TextButton`s (edit profile, key packages, change account,
profile keys, network relays) and two filled `Button`s. A filled button is M3's highest
emphasis and is meant for one action per screen, so this was two competing primaries -- and
the more prominent of the pair was the list's most destructive item.

Sharing is now `FilledTonalButton`: it is the useful action, at medium emphasis rather than
maximum. Signing out is a `TextButton` in the error colour, which is not a new pattern --
it is how leaving and deleting a group are already treated in `ChatRoomDetailScreen`.
Screenshot verified on emulator-5554: one tonal button, one red text button, five plain
ones, and a hierarchy a reader can follow.

**The plan was wrong about disabled FABs, and the code was right.** It said five screens
should stop hand-computing a container colour from a `can…` flag and pass `enabled`
instead. **No `FloatingActionButton` overload in material3 1.10 takes `enabled`** -- checked
in the source, zero matches for `enabled: Boolean` in FloatingActionButton.kt -- because
the spec's own position is that an unavailable FAB should not appear at all. Hand-computing
is the only way to show a disabled one.

More to the point, the existing code is already better than the plan assumed: it pairs the
colour with `Modifier.semantics { disabled() }` and a comment saying "looking unavailable
is not being unavailable: without this a screen reader still announces a button it is happy
to press." Left alone, and the plan corrected.

**Eight screens are left for a person.** LandingScreen puts "Sign in" beside "Create
profile", SocialPreconditionScreen puts "Invite a friend" beside "View invites", and six
others do the same. Both members of each pair are filled buttons. Which one is primary is a
product decision about what the screen is *for*, and picking wrong quietly weights a choice
the user is supposed to make freely -- so this is listed in the plan rather than guessed at
here.

**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. Both
changes are composition-time rendering, which this repo has no UI test infrastructure to
assert; the device screenshot stands in for it. `:composeApp:compileDebugKotlinAndroid`
builds and the apk runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 02:01:39 +02:00
parent 44bf2a01f0
commit 043d725599
3 changed files with 73 additions and 36 deletions

View File

@@ -67,6 +67,8 @@ import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_
import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ButtonDefaults
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -195,7 +197,13 @@ fun ActiveProfileScreen(
modifier = Modifier.weight(1f)
)
Button(
// The list this sits in is otherwise TextButtons --
// edit profile, key packages, change account, profile
// keys, network relays. Two of the seven were filled
// Buttons, which is M3's highest emphasis and is meant
// for one action per screen. Sharing is the useful one,
// so it keeps medium emphasis rather than maximum.
FilledTonalButton(
onClick = {
onNavigateToRoute.invoke(
ShareProfileRoute(
@@ -324,7 +332,15 @@ fun ActiveProfileScreen(
}
item {
Button(
// Signing out was the *other* filled Button -- the most
// prominent control on the screen given to its most
// destructive action. It now matches how leaving and
// deleting a group are already treated in
// ChatRoomDetailScreen: a TextButton in the error colour.
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Sign out")

View File

@@ -1,7 +1,9 @@
package press.mantra.compose.ui.composable.widgets
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.LoadingIndicatorDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -14,10 +16,23 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import press.mantra.compose.ui.theme.spacing
/**
* The app's one loading state, used at 41 call sites.
*
* It draws M3's `LoadingIndicator` rather than a `CircularProgressIndicator`. That is the
* expressive component for exactly this -- an indeterminate wait with no progress to
* report -- and it is what `MaterialExpressiveTheme` expects to be paired with.
*
* The colour default moved from `secondary` to the component's own
* `LoadingIndicatorDefaults.indicatorColor`. `secondary` is the brand gold, which read as
* a warning rather than as a wait, and the previous version also hardcoded an 80dp width
* where the component has a size of its own.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LoadingDataIndicator(
modifier: Modifier = Modifier.fillMaxWidth(),
color: Color = MaterialTheme.colorScheme.secondary,
color: Color = LoadingIndicatorDefaults.indicatorColor,
fillScreen: Boolean = true,
text: String? = null
) {
@@ -29,11 +44,7 @@ fun LoadingDataIndicator(
Spacer(modifier = Modifier.weight(1f))
}
CircularProgressIndicator(
modifier = Modifier.width(80.dp).aspectRatio(1f),
color = color,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
LoadingIndicator(color = color)
text?.let {