Skip to content

Adopt Material 3 Expressive: adaptive navigation, list-detail pane, expressive components#484

Merged
erikeelde merged 36 commits into
mainfrom
feature/m3-expressive
Jun 20, 2026
Merged

Adopt Material 3 Expressive: adaptive navigation, list-detail pane, expressive components#484
erikeelde merged 36 commits into
mainfrom
feature/m3-expressive

Conversation

@erikeelde

Copy link
Copy Markdown
Owner

Summary

  • Adaptive navigation: replaces ModalNavigationDrawer with NavigationSuiteScaffold — auto-renders a bottom NavigationBar on compact screens and a NavigationRail on expanded screens
  • List-detail pane: applies the material3-adaptive-navigation3 scene strategy to NavDisplay so the configurations list and value editors render side-by-side on expanded screens (tablet/foldable) and as push navigation on compact
  • MaterialExpressiveTheme: swaps MaterialTheme for MaterialExpressiveTheme with MotionScheme.expressive() throughout the app; requires material3 1.5.0-alpha20 (pinned — public Expressive APIs not yet in BOM 2026.05.01) and compileSdk/targetSdk 37
  • Boolean editor: Switch + button row replaced with ToggleButtonGroup (Off/On) + ButtonGroup (Revert/Save)
  • Integer / String / Enum editors: Revert/Save rows wrapped in ButtonGroup
  • Toggle-gated enum list (dogfooding): EnumValueViewModel injects Toggles (via the already-wired Hilt binding) and reads "expressive_enum_list" as a reactive boolean flag; when ON the enum option list shows RadioButton selection instead of the Link icon — flip the toggle under the se.eelde.toggles app to compare at runtime

Dependency changes

  • Compose BOM: 2026.03.012026.05.01
  • material3 pinned to 1.5.0-alpha20 (Expressive APIs public here, not in 1.4.0/BOM)
  • compileSdk / targetSdk: 36 → 37 (required by material3 1.5.0-alpha20 → Compose 1.12.0-alpha)
  • New: material3-adaptive-navigation-suite, material3-adaptive, material3-adaptive-layout, material3-adaptive-navigation3 1.3.0-beta02
  • Robolectric migration tests pinned to SDK 36 (@Config(sdk = [36])) to match Robolectric 4.16.x max

Test plan

  • ./gradlew detekt passes
  • ./gradlew test passes
  • ./gradlew assembleAndroidTest compiles
  • Phone emulator (compact): bottom NavigationBar with Applications / Licenses / Help; tapping a toggle pushes the value editor full-screen; back returns to the list
  • Tablet / unfolded foldable (expanded): NavigationRail visible; configurations list and value editor render side-by-side
  • Boolean editor: Off/On toggle group + Revert/Save button group
  • Dogfooding loop: open an enum config (flag defaults OFF → Link icon list); in Toggles open se.eelde.toggles app → flip expressive_enum_list ON; reopen enum config → RadioButton list

🤖 Generated with Claude Code

erikeelde and others added 11 commits May 30, 2026 00:51
Documents the plan to adopt adaptive navigation (NavigationSuiteScaffold +
adaptive-navigation3 list-detail), MaterialExpressiveTheme with the expressive
motion scheme, and Expressive components on the value-editor screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enum option lists can be long, so use a proper scrollable selectable list
instead of ToggleButtonGroup, gated behind a dogfooded boolean feature toggle
(read via toggles-flow) so the new behavior can be tested at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task-by-task plan covering adaptive navigation, list-detail pane, expressive
components on the value editors, and the toggle-gated enum list, derived from
the approved design spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MaterialExpressiveTheme and MotionScheme are public in material3 1.5.0-alpha20
(not yet in 1.4.0/BOM 2026.05.01), so pin material3 explicitly. That version
requires compileSdk/targetSdk 37 and Compose 1.12.0-alpha; pin Robolectric
tests to SDK 36 to match Robolectric 4.16.x's maximum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the ModalNavigationDrawer/hamburger-menu pattern from ApplicationEntry
and replaces it with NavigationSuiteScaffold, which automatically renders a
bottom NavigationBar on compact screens and a NavigationRail on expanded screens.
The Navigation 3 NavBackStack is hoisted into a new TogglesApp composable and
passed down to Navigation(), keeping it as the single source of truth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire ListDetailSceneStrategy into NavDisplay so the configurations list
and value-editor entries render side-by-side on expanded screens and as
push navigation on compact screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a dependency constraint on androidx.concurrent:concurrent-futures to
require 1.2.0, resolving a consistent-resolution conflict between
profileinstaller:1.4.0 (which pins 1.1.0) and androidx.test:core:1.7.0
(which requires 1.2.0) that caused assembleAndroidTest to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 30, 2026 05:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 20 out of 20 changed files in this pull request and generated 4 comments.

Comment on lines +158 to 163
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography,
motionScheme = MotionScheme.expressive(),
content = content
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a problem. CI compiles with -Werror / allWarningsAsErrors and the build job is green — this call site emits no opt-in warning for MaterialExpressiveTheme or MotionScheme.expressive in the Compose version used here, so there is nothing to opt into.

Comment thread toggles-app/src/main/java/se/eelde/toggles/MainActivity.kt
Comment thread toggles-app/src/main/java/se/eelde/toggles/MainActivity.kt Outdated
erikeelde and others added 16 commits June 1, 2026 14:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Matches the design spec and the codebase convention; the toggle flow is
ContentObserver-backed, so collection should pause while the activity is stopped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolveFlow ran resolver.resolve() — including ContentResolver insert
mutations for a missing configuration — on the collector's thread. For the
in-process dogfood case there is no binder hand-off, so it ran on main and
Room's main-thread guard crashed. Add .flowOn(ioDispatcher) so resolution and
its mutations always run on IO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The value editors rendered the title twice: once in the TopAppBar (a static
type label) and once as a headline in the body. Drop the body heading and set
the TopAppBar title to the toggle key (viewState.title).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dialog-mode editors rendered their full-screen Scaffold inside the Dialog, so a
short editor (e.g. boolean) stretched to ~full screen with a large empty area.
Add a shared ToggleEditorDialog container that sizes to content, clamped to
[200dp, 560dp]; list editors cap and scroll via weight(fill = !asDialog). The
non-dialog (pane/full-screen) Scaffold path is unchanged. Scope editor still
pending extraction into its own module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Moves ScopeView/ScopeViewModel (and their string resources) out of toggles-app
into a dedicated module, mirroring the other configuration editor modules, and
gives the Scope editor the same content-wrapped dialog presentation as the
others (asDialog branch + LazyColumn weight(fill = !asDialog)).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The detailPlaceholder was a bare Text, so on a tablet it rendered top-center
overlapping the status bar instead of centred in the detail pane. Wrap it in a
fillMaxSize Box with center alignment and a muted style.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…njection

CI compiles with -Werror and lint warningsAsErrors. Suppress the deprecated
ButtonGroup overload usage in the four editors (the supported overload needs a
different overflow DSL; tracked as follow-up), reorder BooleanValueView params
so the required asDialog precedes the defaulted viewModel (ComposeParameterOrder),
and hoist MainViewModel to a Navigation parameter (ComposeViewModelInjection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regenerate baselines (NotShrinkingResources, SimilarGradleDependency, the
relocated DenyListedApi for the extracted scope module, and noop/sample issues)
so the warningsAsErrors lint pass is green. No source behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The androidTest source set also compiles with -Werror in CI; suppress the
deprecated createEmptyComposeRule (the v2 overload swaps in StandardTestDispatcher
and would change test timing). Unblocks the emulator-tests compile step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derive the highlighted destination from the back stack (so it stays correct after
Back) and pop to an existing top-level route instead of appending duplicate
entries on re-tap. Mark the nav-item icon contentDescription null since the label
already provides the accessible name (avoids double announcement).

Addresses Copilot review comments on PR #484.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
erikeelde and others added 9 commits June 1, 2026 16:32
…ertion

The test crashed in CI with 'component was not created' because the Hilt and
compose rules had no explicit order; order HiltAndroidRule first and inject() in
@before. Also replace the racy 'No applications found.' empty-state assertion —
reading the dogfood toggle on launch self-registers this app — with a stable
check that the Applications home renders. Verified locally on an emulator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ted up from)

Per Material guidance a dialog is dismissed, not navigated up from, so it has no
up/back navigation icon. Remove the navigationIcon from ToggleEditorDialog; the
editors are dismissed via their own Revert/Save actions, the scrim, or system
back. The non-dialog pane/full-screen path keeps the up-arrow (it is a navigated
destination).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The configurations list previously jammed a Material 3 SearchBar (a
component designed to expand into an overlay) into a TopAppBar title
with expanded=false and empty content, plus an unremembered `searching`
flag that reset every recomposition.

Swap it for the proper M3 Expressive search components (material3
1.5.0-alpha20): AppBarWithSearch in the top bar (keeping the back
navigation icon and overflow menu) and ExpandedFullScreenSearchBar for
the expanded results surface, both sharing one inputField bound to a
TextFieldState. The TextFieldState bridges into the existing ViewModel
query pipeline via snapshotFlow, so the SavedStateHandle-backed
queryString/Room LIKE filter remains the source of truth.

Also tidy ConfigurationViewModel: TextUtils.isEmpty -> isEmpty().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use collectAsStateWithLifecycle() to seed the search text field instead of
reading StateFlow.value directly in composition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ode)

In adaptive list-detail layout, the back arrow in the configurations detail
pane should only appear in single-pane mode. Use LocalListDetailSceneScope
and its scaffoldTransitionScope.targetState.secondary to detect whether the
list pane is currently visible and suppress the navigation icon accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e mode)

Adds rememberShowNavigationIconInDetailPane/ExtraPane() helpers to compose-theme
that check LocalListDetailSceneScope to determine whether the pane a navigation
icon would lead back to is already visible. All five leaf editors (Boolean,
String, Integer, Enum, Scope) now suppress their back arrow in three-pane mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ane state

PaneScaffoldTransitionScope does not expose targetState directly; it must be
accessed via scaffoldStateTransition (a Compose Transition<ThreePaneScaffoldValue>)
which does expose targetState.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split the pane-state read across lines to stay under the 120-char limit, and
render the back arrow via an always-present navigationIcon lambda with a
conditional body (matching the leaf editors) instead of a nullable if-expression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
toggles-flow observes the Toggles ContentProvider via a callbackFlow. If a
contentResolver.query() throws (provider process died, mid-upgrade, or — under
instrumentation tests — the provider's Hilt component torn down between tests),
the exception propagated out of the flow and crashed the host app. This is what
broke the emulator-tests job: ExampleInstrumentationTest2 launches MainActivity,
which dogfoods the editor-presentation toggle through toggles-flow, and the
background observation raced with Hilt teardown.

Wrap the three provider queries in safeQuery(), treating a thrown
RuntimeException like a null cursor so the client degrades to the supplied
default instead of crashing. The intentional error() invariants in
queryConfiguration (which fire after a successful query) are preserved.

Adds ProviderThrowsTest, which registers a provider that throws on query and
asserts the flow still emits the default. Verified it fails without the fix
with the same IllegalStateException signature seen in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@erikeelde erikeelde merged commit 2dba606 into main Jun 20, 2026
4 checks passed
@erikeelde erikeelde deleted the feature/m3-expressive branch June 20, 2026 03:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants