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:
1 parent
043d725599
commit
03d1e8e3b1
6 files changed
+290
-19
No files matched your search
@@ -0,0 +1,123 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The breakpoint thresholds against M3's published values, and the spacing they select.
|
||||
*
|
||||
* The thresholds are the kind of number that is wrong silently: a layout that switches to
|
||||
* two panes at 640dp instead of 600dp still looks like a working layout on every device
|
||||
* anybody happens to test, and only misbehaves in the 40dp nobody opens. Asserting the
|
||||
* boundary from both sides is the only way that shows up.
|
||||
*/
|
||||
class BreakpointTest {
|
||||
|
||||
/** m3.material.io/foundations/layout/applying-layout/window-size-classes, September 2026. */
|
||||
private val published = listOf(
|
||||
Breakpoint.Compact to 0,
|
||||
Breakpoint.Medium to 600,
|
||||
Breakpoint.Expanded to 840,
|
||||
Breakpoint.Large to 1200,
|
||||
Breakpoint.ExtraLarge to 1600,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `each breakpoint starts at its published width`() {
|
||||
assertEquals(published.size, Breakpoint.entries.size, "a breakpoint was added or removed")
|
||||
published.forEach { (breakpoint, lowerBound) ->
|
||||
assertEquals(
|
||||
lowerBound.dp,
|
||||
breakpoint.minWidth,
|
||||
"$breakpoint starts at ${breakpoint.minWidth}, M3 says $lowerBound.dp",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lower bound belongs to the breakpoint it opens`() {
|
||||
// Half-open on the upper bound: exactly 600dp is Medium, and 599dp is Compact.
|
||||
// Off by one here and a phone in landscape gets the tablet layout, or does not.
|
||||
published.forEach { (breakpoint, lowerBound) ->
|
||||
assertEquals(
|
||||
breakpoint,
|
||||
Breakpoint.ofWidth(lowerBound.dp),
|
||||
"${lowerBound}dp should be exactly at the bottom of $breakpoint",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one dp below a lower bound is the breakpoint beneath it`() {
|
||||
published.drop(1).forEachIndexed { indexBefore, (breakpoint, lowerBound) ->
|
||||
val beneath = published[indexBefore].first
|
||||
assertEquals(
|
||||
beneath,
|
||||
Breakpoint.ofWidth((lowerBound - 1).dp),
|
||||
"${lowerBound - 1}dp fell in $breakpoint rather than $beneath",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the widths the phase is meant to be checked at land where the plan says`() {
|
||||
// The five window widths phase 6's acceptance criterion names. If one of these
|
||||
// ever moves, the manual check and the code have stopped talking about the same
|
||||
// thing.
|
||||
assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(400.dp))
|
||||
assertEquals(Breakpoint.Medium, Breakpoint.ofWidth(700.dp))
|
||||
assertEquals(Breakpoint.Expanded, Breakpoint.ofWidth(1000.dp))
|
||||
assertEquals(Breakpoint.Large, Breakpoint.ofWidth(1400.dp))
|
||||
assertEquals(Breakpoint.ExtraLarge, Breakpoint.ofWidth(1800.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero or negative width is compact rather than an error`() {
|
||||
// A window has zero width for one frame on desktop, before the first layout pass,
|
||||
// and `ofWidth` is called from the theme every composition. Throwing there would
|
||||
// take the app down on a resize.
|
||||
assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(0.dp))
|
||||
assertEquals(Breakpoint.Compact, Breakpoint.ofWidth((-1).dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isAtLeast reads up the scale and not down it`() {
|
||||
assertTrue(Breakpoint.Large.isAtLeast(Breakpoint.Expanded))
|
||||
assertTrue(Breakpoint.Expanded.isAtLeast(Breakpoint.Expanded))
|
||||
assertTrue(!Breakpoint.Medium.isAtLeast(Breakpoint.Expanded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen margin widens at medium and holds there`() {
|
||||
// M3 publishes 16dp compact, 24dp for every wider breakpoint -- it does not keep
|
||||
// growing. A margin that scaled with the window would push a bounded column of
|
||||
// text further from the edge for no reason at 1800dp.
|
||||
assertEquals(16.dp, spacingFor(Breakpoint.Compact).screenMargin)
|
||||
listOf(Breakpoint.Medium, Breakpoint.Expanded, Breakpoint.Large, Breakpoint.ExtraLarge)
|
||||
.forEach { assertEquals(24.dp, spacingFor(it).screenMargin, "$it") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing but the screen margin moves with the breakpoint`() {
|
||||
// The scale itself is absolute -- space200 is 16dp in every window -- and the
|
||||
// other semantic names are component decisions. This is the assertion that stops
|
||||
// a later "make it breathe on desktop" edit from quietly turning the whole scale
|
||||
// into a zoom factor.
|
||||
val compact = spacingFor(Breakpoint.Compact)
|
||||
val wide = spacingFor(Breakpoint.ExtraLarge)
|
||||
|
||||
assertEquals(compact, wide.copy(screenMargin = compact.screenMargin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every breakpoint gets one of two shared instances`() {
|
||||
// LocalSpacing is a static composition local: it invalidates on identity, not on
|
||||
// equality, so handing it a freshly built but equal Spacing every recomposition
|
||||
// would restart every composition that reads spacing.
|
||||
assertSame(spacingFor(Breakpoint.Medium), spacingFor(Breakpoint.ExtraLarge))
|
||||
assertSame(spacingFor(Breakpoint.Compact), spacingFor(Breakpoint.Compact))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user