feat: give the app the five breakpoints, and let the screen margin follow them

Phase 6, steps 1 and 2. The app had no notion of window width at all -- two
`BoxWithConstraints` in 30,000 lines of UI, both inside a view model -- so every
layout decision in it was made once, for a phone, and then rendered unchanged
into a 1800dp desktop window.

**The dependency question the plan asked to settle first.** `material3-adaptive`
publishes multiplatform under `org.jetbrains.compose.material3.adaptive`, with
android, desktop and ios variants; the ios ones carry `ios_arm64` and
`ios_simulator_arm64` attributes despite the `uikit*` artifact names, so the
targets this project declares on a mac resolve. Version **1.2.0**, not the newer
1.3.0-beta02, because that is the version the pinned material3 itself resolves:
`material3-adaptive-navigation-suite:1.10.0-alpha05` names `adaptive:1.2.0` in
its pom, and 1.3.0 would pull window-core 1.5.0 in beside the 1.4.0 the pinned
material3 compiled against. Nothing is lost by staying: 1.2.0 already computes
the large and extra-large breakpoints through `supportLargeAndXLargeWidth`, and
carries `ListDetailPaneScaffold` for the pane work. So steps 3-4 can use the
library scaffolds rather than a hand-rolled equivalent.

**`Breakpoint`** is the five-value enum -- compact / medium / expanded / large /
extra-large at 0 / 600 / 840 / 1200 / 1600dp -- with `ofWidth` as a pure function
so the thresholds are assertable without a Compose runtime. `TorchTheme`
classifies once and provides `LocalBreakpoint`, so no two screens can disagree
about the window they are both in.

It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`.
The latter also computes a `Posture` from the platform's fold state, which on
android reaches for `WindowInfoTracker` and an activity; this call sits in
`TorchTheme`, which wraps all 51 `@Preview` bodies in the tree, and a preview
context is not an activity. The pane scaffolds ask for posture themselves, at
the one place a fold changes the answer.

**Spacing now adapts, and exactly one value moves.** M3 publishes a margin per
breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else
that varies with window width. The scale itself is absolute: `space200` is 16dp
on a phone and 16dp on a desktop, and what adapts is which token a job reaches
for, not the token. So `screenMargin` goes 16 -> 24 at medium and holds there,
and `containerPadding`, `itemGap` and the rest do not move -- a card does not
become a different component because the window grew. Widening all of them is
the "everything breathes on a big screen" instinct, and it reads as a zoomed
phone rather than as a layout. A test asserts the non-movement, because that is
the edit a later reviewer would wave through.

Mechanically this made the eight semantic names constructor parameters instead
of `get()`s over the scale, so a breakpoint can reassign one without moving the
stop underneath it. Kotlin resolves a default expression against the parameters
before it, so each still reads its stop by name and still follows it when the
scale is overridden -- phase 2's `Spacing(space200 = 24.dp)` assertion holds
unchanged. The two instances are singletons because `LocalSpacing` is a
`staticCompositionLocalOf` and invalidates on identity, not equality.

**A test found a real defect while being written.** `ofWidth` was
`entries.last { width >= it.minWidth }`, which throws `NoSuchElementException`
below 0dp. A desktop window reports a zero size for the frame before its first
layout pass, and this is called from the theme on every composition, so the
crash would have arrived on a resize rather than on anything a user did. Now
total.

`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` both
green; 28 theme tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 07:15:43 +02:00
parent 043d725599
commit 03d1e8e3b1
6 changed files with 290 additions and 19 deletions

View File

@@ -0,0 +1,102 @@
package press.mantra.compose.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowDpSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* M3's five window width breakpoints.
*
* Renamed from "window size class" in the May 2026 revision, which also grew the set from
* three to five: [Large] and [ExtraLarge] were split off the old expanded class because a
* 1600dp window and an 850dp one want different numbers of panes. Values are from
* m3.material.io/foundations/layout/applying-layout/window-size-classes, read September
* 2026.
*
* | breakpoint | width | panes | navigation |
* |--------------|-------------|------------------------|-------------------------|
* | [Compact] | under 600dp | 1 | navigation bar |
* | [Medium] | 600839dp | 1 recommended | collapsed rail |
* | [Expanded] | 8401199dp | 2 recommended | collapsed/expanded rail |
* | [Large] | 12001599dp | 2 recommended | expanded rail |
* | [ExtraLarge] | 1600dp+ | up to 3 | expanded rail |
*
* The classification is on the **window**, not on the composable being measured. That is
* the distinction between this and `BoxWithConstraints`: a pane 300dp wide inside a
* 1400dp window is still in a large layout, and should not start behaving like a phone.
* Anything wanting the local constraints should still measure them.
*
* @property minWidth the narrowest window that falls in this breakpoint.
*/
enum class Breakpoint(val minWidth: Dp) {
Compact(0.dp),
Medium(600.dp),
Expanded(840.dp),
Large(1200.dp),
ExtraLarge(1600.dp);
/**
* `true` when this breakpoint is [other] or anything wider.
*
* The comparison call sites want almost always. Enum ordering already answers it, but
* `breakpoint >= Breakpoint.Expanded` reads as a size comparison on a width and is
* one edit away from being wrong if a breakpoint is ever inserted; this says what it
* means.
*/
fun isAtLeast(other: Breakpoint): Boolean = ordinal >= other.ordinal
companion object {
/**
* The breakpoint a window of [width] falls in.
*
* Ranges are half-open on the upper bound -- exactly 600dp is [Medium], not
* [Compact] -- which is how M3 states them and how `WindowSizeClass` computes
* them.
*
* Total, including for a width below [Compact.minWidth]. This is called from
* [TorchTheme] on every composition, and a desktop window reports a zero size for
* the frame before its first layout pass; throwing there would take the app down
* on a resize rather than on anything a user did.
*/
fun ofWidth(width: Dp): Breakpoint =
entries.lastOrNull { width >= it.minWidth } ?: Compact
}
}
/**
* The breakpoint the current window is in.
*
* Provided by [TorchTheme] as [LocalBreakpoint], so screens read
* `MaterialTheme.breakpoint` rather than calling this. It is public because the two
* entry points that compose above the theme -- the desktop passphrase gate, and any
* future splash -- have nowhere else to get it.
*
* **Why the window size and not `currentWindowAdaptiveInfo()`.** The latter also computes
* a [androidx.compose.material3.adaptive.Posture] from the platform's fold state, which
* on android means reaching for `WindowInfoTracker` and an `Activity`. This is called
* from [TorchTheme], which wraps every `@Preview` in the tree, and a preview context is
* not an activity. `currentWindowDpSize()` is `LocalWindowInfo` and `LocalDensity` and
* nothing else, so it answers everywhere. The pane scaffolds ask for posture themselves,
* where a fold genuinely changes the answer.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun currentBreakpoint(): Breakpoint = Breakpoint.ofWidth(currentWindowDpSize().width)
/**
* Defaults to [Breakpoint.Compact] rather than throwing, because that is the layout every
* screen in this app was written against: a composable that never reaches a [TorchTheme]
* should render as it did before breakpoints existed, not fail.
*/
val LocalBreakpoint = staticCompositionLocalOf { Breakpoint.Compact }
/** `MaterialTheme.breakpoint`, to match `MaterialTheme.spacing` and `MaterialTheme.colorScheme`. */
val MaterialTheme.breakpoint: Breakpoint
@Composable
@ReadOnlyComposable
get() = LocalBreakpoint.current

View File

@@ -19,11 +19,10 @@ import androidx.compose.ui.unit.dp
* m3.material.io/m3/pages/spacing/tokens, read September 2026; see
* docs/material-design-conformance.md for the full table.
*
* **Why a `data class` and not constants.** Nothing scales today, but two things are
* coming that need to: spacing adapts across breakpoints, and M3 has a density setting
* for data-heavy views. Both are a matter of providing a different [Spacing] instance
* rather than of touching a call site, and that is only true if the values arrive through
* the local. A file of top-level `val`s would read the same and adapt to nothing.
* **Why a `data class` and not constants.** So that a different instance can be provided
* without touching a call site, which is what [spacingFor] now does per [Breakpoint] and
* what M3's density setting for data-heavy views would do next. A file of top-level
* `val`s would read the same at the call site and adapt to nothing.
*/
@Immutable
data class Spacing(
@@ -45,7 +44,7 @@ data class Spacing(
val space700: Dp = 56.dp,
val space800: Dp = 64.dp,
val space900: Dp = 72.dp,
) {
// -----------------------------------------------------------------------
// Semantic names
// -----------------------------------------------------------------------
@@ -65,30 +64,64 @@ data class Spacing(
// aren't uniform, and require more tokens" -- so there is exactly one margin here,
// for the screen edge, and everything else is padding or a gap.
// They are constructor parameters rather than `get()`s over the scale so that a
// breakpoint can reassign one without moving the stop underneath it. That direction
// matters: M3's spacing tokens are absolute values that do not change with window
// width -- `space200` is 16dp on a phone and 16dp on a desktop -- and what adapts is
// which token a given job reaches for. A wider window takes a wider screen margin, it
// does not take a wider 16.
//
// Kotlin resolves a default expression against the parameters before it, so each of
// these still reads its stop by name and follows it when the scale itself is
// overridden. `Spacing(space200 = 24.dp)` still moves `screenMargin`.
/** Screen edge to content. The one margin; everything inside a screen is padding or a gap. */
val screenMargin: Dp get() = space200
val screenMargin: Dp = space200,
/** Inside a card, dialog, sheet or list row: container edge to its content. */
val containerPadding: Dp get() = space200
val containerPadding: Dp = space200,
/** Inside a compact container -- a chip, a badge, a dense row. */
val compactPadding: Dp get() = space100
val compactPadding: Dp = space100,
/** Between two elements that belong to the same thought: a label and its value. */
val relatedGap: Dp get() = space50
val relatedGap: Dp = space50,
/** The default gap between items in a list or column. */
val itemGap: Dp get() = space100
val itemGap: Dp = space100,
/** Between one group of content and the next within a screen. */
val sectionGap: Dp get() = space300
val sectionGap: Dp = space300,
/** Around a lone element that needs to stand apart -- an empty state, a hero action. */
val emphasisGap: Dp get() = space500
val emphasisGap: Dp = space500,
/** Between adjacent touch targets, which M3 asks to be at least 8dp apart. */
val targetGap: Dp get() = space100
}
val targetGap: Dp = space100,
)
/**
* The spacing a window at [breakpoint] should use.
*
* Exactly one value moves, and that is not an oversight. M3 publishes a margin per
* breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else that
* varies with window width: padding inside a card and the gap between two list rows are
* component decisions, and a card does not become a different component because the
* window grew. Widening them all would be the "everything breathes on a big screen"
* instinct, which reads as a zoomed phone rather than as a layout.
*
* What actually fills a wide window is a second pane and a bounded measure, not fatter
* gaps. Those are layout, and they live in `AdaptiveContent` rather than here.
*/
fun spacingFor(breakpoint: Breakpoint): Spacing =
if (breakpoint == Breakpoint.Compact) CompactSpacing else MediumAndWiderSpacing
// Held as singletons rather than built per call. `LocalSpacing` is a
// `staticCompositionLocalOf`, so a provider that hands it a fresh but equal instance on
// every recomposition would restart every composition reading it; `Spacing` is a data
// class, but static locals compare by identity when deciding whether to invalidate.
private val CompactSpacing = Spacing()
private val MediumAndWiderSpacing = Spacing(screenMargin = 24.dp)
val LocalSpacing = staticCompositionLocalOf { Spacing() }

View File

@@ -505,12 +505,17 @@ fun TorchTheme(
val colorScheme = dynamicColorScheme(darkTheme, dynamicColor)
?: appColorScheme(darkTheme, contrast)
// Classified once, here, so that every screen below reads the same answer. Doing it
// per screen would let two of them disagree about the window they are both in, which
// is the failure mode of `BoxWithConstraints`-per-screen adaptivity.
val breakpoint = currentBreakpoint()
CompositionLocalProvider(
LocalExtendedColors provides extendedColorsFor(darkTheme),
// Nothing scales it yet. It rides the theme now so that the breakpoint phase can
// provide a wider instance without touching a call site -- which is the whole
// reason for tokenising spacing rather than leaving it in literals.
LocalSpacing provides Spacing(),
LocalBreakpoint provides breakpoint,
// The payoff for tokenising spacing in phase 2: the screen margin widens from
// 16dp to 24dp at medium and above without a single call site changing.
LocalSpacing provides spacingFor(breakpoint),
) {
MaterialExpressiveTheme(
colorScheme = colorScheme,