Skip to content

refactor: share code between the styles in lib, and fix 18 bugs found on the way - #215

Merged
yadaniyil merged 63 commits into
masterfrom
refactor/simplify-lib
Oct 8, 2026
Merged

yadaniyil merged 63 commits into
masterfrom
refactor/simplify-lib

Conversation

@yadaniyil

Copy link
Copy Markdown
Contributor

Connection with issue(s)

No issue. This is maintenance: a cleanup of lib/, plus the bugs found while doing it.

What changed

Refactor (38 refactor: commits): no change to the public API, the look or the behavior

  • Switches: the four painted switches share their State scaffold, drag handling and RTL mirror in settings_switch_base.dart.
  • Tiles: the dispatcher builds one SettingsTileData instead of forwarding 17 to 20 arguments six times. The Android and web tiles are two configurations of MaterialTileRow. Press tracking, Enter/Space activation and the title column are shared.
  • Desktop sidebars: the macOS, Windows and GNOME rows, bar buttons and sections share sidebar_row.dart, sidebar_button.dart and sidebar_section.dart. fluent_split.dart is split into pane, page header, compact pane and controls.
  • Split view: settings_split_view.dart went from 1,669 to 784 lines. Routes, focus and pane builders are part files, and what differs per style is behind SplitPaneStyle.
  • Size: code lines in lib/ 11,941 → 10,902. Largest file 1,780 → 895 lines.

Bug fixes (18 fix: commits)

Each fix has a test that failed before it. The full list is in CHANGELOG.md under [Unreleased]. In short:

  • No more errors when a Windows or GNOME row, a Windows pane item or a FluentSettingsSwitch leaves the tree while pressed.
  • Keyboard: the iOS page header's back button takes focus; Enter presses the desktop bars' buttons on the web.
  • Screen readers: custom tiles in the desktop sidebars, the Windows rail's labels, the GNOME sidebar's enabled state.
  • Split view: the list pane's safe-area padding with two panes, the iOS bar title after a style change, the web menu's separator around an empty section, state kept in a Windows pane item.
  • Tiles and switches: a dead tap on Android and web switch rows without onToggle, MacosSettingsSwitch centering, SettingsThemeData doc comments corrected to match the code.

Testing and Review Notes

  • dart format and flutter analyze .: clean, also in example/.
  • flutter test: 876 pass. The 733 tests that existed before are unchanged; 143 are new.
  • Pixels: a temporary harness (not part of this PR) recorded 1,720 images on 4.0.1: every example screen, a list with every tile option in all seven styles, the split views at four widths, and each switch frame by frame, in light, dark, RTL and 2x text. The final code matches all of them.
  • Public API: the dartdoc member list is identical to 4.0.1.
  • example/integration_test on an iOS simulator: 21 gallery tests and 30 split view flow tests pass.
  • Two independent old-versus-new comparisons of callbacks, navigator stacks, focus and semantics found one regression from the refactor (the list pane read the app's sections list without copying it). It is fixed and covered by a test.

Not checked: real macOS, Windows and Linux desktop builds, a real screen reader, release mode.

Heads-up for reviewers:

  • Files under package:settings_ui/src/ moved and changed (for example, the platform tile widgets now take one SettingsTileData). They were never public API, but apps that import them will need updates.
  • The commits are small. The refactor: commits come first, then the fix: commits, so the two halves can be read separately.

To Do

  • At release: bump the version and date the [Unreleased] section of the changelog.

🤖 Generated with Claude Code

yadaniyil and others added 30 commits October 8, 2026 19:24
The four painted switches repeated the same shell. It now lives once in the
internal settings_switch_base.dart:

- SettingsSwitchState: the Space/Enter actions, reduced motion, text
  direction, focus highlight, drag-down position, and the semantics, focus
  and gesture widgets around the painter (buildSwitch).
- SettingsSwitchKnobDrag: the drag of the macOS, Windows and GNOME switches
  (the knob follows the pointer, letting go past the middle picks a side).
  Each style keeps its own settle step, because they differ.
- SettingsSwitchPainter: mirrors the canvas in right-to-left layouts.
- debugFillSwitchProperties and switchBrightnessOf.

The Fluent _SwitchColors class becomes a record, which has the same value
equality without the hand-written == and hashCode.

No visible change: widget trees, callbacks, animation calls and paint order
are the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The press, release and position springs were three copies of the same
  SpringSimulation call. They now go through _spring().
- _paintLens no longer takes the press value, which it never read.
- The focus ring color is only computed while the ring shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iOS, Windows and GNOME painters built the same track rectangle in the
middle of the painted size. It is now SettingsSwitchPainter.centerTrack.
The macOS painter keeps its own (it draws from the top left corner).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing in fluent_settings_switch.dart needs flutter/services.dart: the
mouse cursors come with widgets.dart, and now live in the shared State.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Android and web row

The dispatcher built six 17-20 argument calls and each platform tile
repeated the same constructor and fields. They now take one
SettingsTileData. The Android and web tiles become two configurations of
MaterialTileRow; the web menu item shares their semantics node and the
selected/disabled colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildTileContent was about 210 lines and 14 levels deep. The title and
value column, the tap handler and the state colors are now separate, the
nested color ternaries are flat, and the value uses the shared tileValue.
Drops a Material wrapper that could never be built (the tile only exists
in the iOS style).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ross desktop tiles

The Fluent and GNOME tiles tracked the pressing pointer with the same
code (TilePressTracking), and the macOS, Fluent and GNOME tiles built the
same Space/Enter actions and the same title-over-subtitles column. Their
200+ line build methods are split into named parts and the nested color
ternaries are flat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The padded heading node (Padding > Semantics header > DefaultTextStyle)
was written out in seven places across the six section styles. The iOS
card-corner rules use one _hasFooter helper, and the iOS and web sections
import the files they use instead of the package barrel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he device-platform error

SettingsList looked for the first shown section again for every item it
built; it now does so once per build, and its three loops over shown
sections share one predicate. The Android and web themes share their six
common ColorScheme colors, and the five copies of the
DevicePlatform.device exception become throwUnresolvedPlatform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaterialTileRow builds its end (switch, trailing widget, chevron) in its
own method and creates the Switch only for switch tiles. The macOS row
separates its content from the box around it (padding, pressed tint,
focus ring), so no method there is longer than about 115 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resolveBrightness runs for every Android and web tile and every iOS
switch. MediaQuery.of made them depend on the whole MediaQueryData, so
they rebuilt on any change of it (keyboard insets, padding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The only place that builds a SettingsSplitListScope passes `sidebar`, so
the `sidebar ?? isSplit` default was dead. The flag's doc comment now says
what it really holds (true with two panes in every style), and
SettingsSplitScope.shownId says why it is there although nothing reads it:
it makes widgets that got the controller from SettingsSplitView.of rebuild
when the page changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Android bar and the collapsing title drew the same toolbar row twice,
and the arrow back button and the 22px title existed three and four
times. They are now SettingsArrowBackButton, SettingsHeaderTitle and one
toolbar row, which reads the tablet inset and the back label itself, so
the collapsing delegate loses two fields. settingsHeaderOverBody is the
"header over a body without the top safe area" column of every page.

showTitle is only ever false for the iOS bar: the macOS, Windows, GNOME
and Android branches no longer handle it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A move only, no code changes. split_view_routes.dart holds the pages of
the two navigators and their observers; split_view_focus.dart holds the
pane focus action and the state's four focus methods, as an extension on
the state so their names and call sites stay the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both observers of the split view kept their navigator's top route with
the same four overrides. A shared _TopRouteObserver does it; the stack
and detail observers add what is theirs, in the same order as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The state's fields are grouped by what they describe (the pick, the last
layout, the two navigators, the list pane), and the one-pane navigation
flags say what each one means. Three notification fields become one
record. build() reads as three steps: _readDestinations, _computeGeometry
(with _findHinge) and _buildLayout, whose bookkeeping is _recordLayout.
Every side effect keeps its place in the order.

The Tab actions are created once, not on every build, so the Actions
widget no longer swaps its listeners on each rebuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_buildListPane was 278 lines with eight switches on the style family, and
_buildTwoPanes had two more, so the generic view knew the macOS, Windows,
GNOME, web, iOS and Android details. Each family now has one small
SplitPaneStyle class (split_pane_style.dart) that answers what differs:
whether rows are sidebar rows, the list padding, the separators and the
iOS large title among the sections, the header over the list with its
keyboard and window wrappers, the line between the panes, Android's
hidden icons and the web detail column. The view builds the same list
pane for every style, in 55 lines, and no longer imports the macOS and
GNOME sidebars.

The widgets and their order are the same, except that the macOS list's
MediaQuery now sits above its extend-up layout instead of below it. The
list still gets its MediaQuery from the view's context, as before.

Still in the view: the Windows compact rail (FluentCompactPaneLayout and
its open flag), which picking a page closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A move only: the six methods that build the panes (two panes, the stack
navigator, the host page, the detail navigator and its root page, the
list pane) become an extension on the state in a part file, with the same
names and call sites. The state class keeps what the view tracks and
does: the pick, back handling, the layout and its notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The state's back section listed actions and listeners in turn. They are
now two sections: "Back" (what back may do, in order, and the list
pane's leave) and "What the navigators report" (the listeners and
observer callbacks that keep the navigation flags current). Methods only
moved; a comment over each section says how the pieces fit.

The pane parts call the context they carry viewContext, since it is the
split view's and not the pane's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lay and controls

fluent_split.dart mixed the navigation pane, the page header with its
breadcrumb, the compact rail's overlay and the subtle button. The page
header, the overlay layout and the shared controls move to files of
their own; fluent_split.dart re-exports the symbols it had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The macOS capsule halves, the GNOME flat button, the Windows subtle
button and the breadcrumb crumbs each carried the same semantics,
keyboard activation and state tracking. SidebarButton holds it once and
each style only draws its states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The macOS, Windows and GNOME sidebar rows repeated their field list and
their focus / activate plumbing, and the Windows and GNOME rows their
press tracking, which had drifted. SidebarRow holds the fields,
SidebarRowState the focus node, the Enter/Space actions and the tap
handling, and SidebarRowPress follows the pressed pointer; each row
still decides which pointer presses it. SidebarSection holds the
header-over-rows column that the three section widgets shared.

Also moves each row's content line out of its build method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A builder put one more element between each button and its semantics,
and made a breadcrumb crumb rebuild (and its header relayout) on every
press, which it had not done. The buttons now extend SidebarButton and
draw themselves in buildButton, so their element trees are as before,
and a button opts out of the states it does not draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each row laid out its icon, one-line title, trailing widget and switch
in its own Row. SidebarRowState.buildLine builds that line from the
style's icon slot, text styles, spacing and switch; the Windows rail
asks for the icon only. The Windows item's color resolution moves out
of its build method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FluentPageHeader builds the breadcrumb under its own LayoutBuilder,
which answers intrinsic size and dry layout queries without asking its
child, so the render object's computeDryLayout and intrinsic overrides
never ran (no hit in the unit tests or the pixel harness). Its paint,
hit test and semantics visits walk the children without copying them
into a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The back button and the item margins were written out for each of the
pane's layouts; the Column's children are now one collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FluentNavigationItem.build was 256 lines; with its colors, line, pointer
handling and rail tooltip in named methods it is 140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s item

The shared row line no longer knows about the rail: the item builds its
icon-only Row itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sidebar rows and switches

The sidebar rows had their own copy of the tiles' press tracking
(SidebarRowPress) and of the Enter/Space actions, and so had the switch
base. They now use TilePressTracking and tileActivateActions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yadaniyil and others added 25 commits October 8, 2026 19:24
…chIfSeparate

The sidebar rows were the other callers of labelTileSwitch. With them on
SettingsTileData, the function had one caller, which now does the
labelling itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…red lib

List the shared files the refactor created (tile_*.dart, section_header.dart,
settings_switch_base.dart, unresolved_platform.dart, sidebar_*.dart,
split_pane_style.dart, split_view_*.dart, fluent_*.dart) with what each
holds, and correct the names that moved (SidebarRowFocus, labelTileSwitch).
The behavior and design notes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pane-style refactor passed the app's list straight to the pane's lazy
list in some styles. An app that removes sections in place without
rebuilding the view then got a RangeError on scroll, which the copy made
before the refactor prevented. Restore the copy and cover it with a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…essed

A pointer that went down on the switch keeps reporting to its Listener
after the switch has left the tree. Its release then animated the press
controller, which was already disposed ("AnimationController.animateTo()
called after AnimationController.dispose()").

The same scenarios (removed while held, mid-drag and while settling) are
tested on the Cupertino, macOS and GNOME switches, which were fine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pointer that went down on a row keeps reporting to its Listener after
the row has left the tree. TilePressTracking then asked the unmounted
State for its render box ("This widget has been unmounted, so the State
no longer has a context").

Windows tiles and sidebar rows threw on every move. GNOME ones threw only
when the row went away before the tap was recognized as down (a touch,
in the first 100 ms): after that, the cancelled tap released the press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Given more room than its 36x16 or 44x20 track (tight constraints), the
switch painted the track from the top-left corner of its box. It now
paints it in the middle, like the other three switches, in left-to-right
and right-to-left layouts. At its own size nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…diagnostics

debugFillProperties left the two out. They show when set and stay hidden
at their defaults, so a switch that sets neither prints as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
titleTextStyle said titleTextColor applies only when the style has no
color. Only the macOS style works that way: the other five always draw
the section title in titleTextColor (their default when it is null). The
comments of both fields now say what each style does, in lists and in a
split view's list pane, and a test pins it. No behavior changes.

Other field comments checked against the code and corrected:
inactiveTitleColor, inactiveSubtitleColor, inactiveSwitchColor,
tileDescriptionTextStyle, selectedTileIconColor and listPaneBackground.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able

A switch tile with onPressed but no onToggle kept the row's ink well
enabled: it showed hover, splash and focus and had a semantics tap
action, but a tap did nothing (these styles toggle on a row tap and
never call onPressed for switch tiles). The row now has no tap handler,
like a switch tile with neither callback already had. The disabled
switch is drawn as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The arrow keys already select only rows with a destination: a row with
onPressed and no destination ("Sign out"), a switch row and a navigation
tile that pushes its own route take the focus and nothing is called. No
code change; the test keeps it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocus

The glass back button only reacted to taps. Tab now reaches it, Enter and
Space press it, and it shows the Cupertino focus ring (3.5pt, inside the
circle) while the keyboard has the focus. Its look at rest, its semantics
node and taps are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SidebarButton (the macOS toolbar buttons, the GNOME flat button, the
Windows subtle button and the breadcrumb crumbs) handled ActivateIntent
only. On the web Enter is ButtonActivateIntent, so it did nothing there.
It now handles both, like the tiles and the sidebar rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tics nodes

In the macOS, Windows and GNOME sidebars, CustomSettingsTiles next to
each other merged into one semantics node, which a screen reader read as
one row. The sidebar sections now wrap them with tileSemanticsNode, like
the list sections of the other styles. No pixels change.

The iOS and web list sections are left as they are: their inner list
already makes each row a node (the tests cover them too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An item of the compact rail took its semantics label from the title's
text and ignored Text.semanticsLabel, so the rail read "Wi-Fi" where the
open pane read the label the app gave. It now uses tileTitleLabel, the
rule of the tiles: the semantics label wins, and a RichText title is read
too. The tooltip and the first-letter icon still show the title's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n action

A GNOME sidebar row told screen readers it was enabled even when it had
nothing to do (no onPressed, or a switch row without onToggle). It now
reports an enabled state only when it has an action, as the GNOME list
tile does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…S and GNOME sidebars

A disabled switch in a macOS or GNOME sidebar row kept its active color
and ignored SettingsThemeData.inactiveSwitchColor, which the list tiles
of both styles use. The sidebar rows now follow the same rule. Without
an inactiveSwitchColor nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FluentNavigationItem put the focus ring and the rail's tooltip around its
content only while they were needed, so the leading widget, the switch
and the fill's fade were recreated whenever the ring showed or hid, or
the rail opened or closed. The ring's CustomPaint now stays in the tree
(without a painter it draws nothing), and the content sits under a
GlobalKey, so it moves in and out of the tooltip with its State. No
pixels change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in paint

The selection pill's painter called back into the item's State, which
looked Directionality up while painting (and after a frame, when another
item asked where its pill was). The item now reads the direction in
build, keeps it for those later calls and hands it to the painter, which
repaints when it changes.

Nothing observable changes, so there is no new test: the existing pill
tests (left to right, right to left, the slide) cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ew's menu

The web style put a separator between every two sections of the menu,
also around a SettingsSection without tiles, which shows nothing: the
line doubled, or showed before the first group or after the last. It now
leaves empty sections out first, like the macOS, Windows and GNOME
sidebars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With two panes the list pane's list still got the window's safe-area
padding of the end side (a phone in landscape with the cutout at the
right), which only the detail pane touches: the list read its MediaQuery
from above the pane. Only the Android style with a title was right. The
pane's content is now built in the pane, so its MediaQuery has no
end-side padding, as the detail pane's has none at the start side. Right
to left it is mirrored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A SettingsSplitView whose platform went from iOS to another style and
back kept showing the title in the bar although its list, built anew,
was at the top with the large title on screen: the view remembered that
the large title had scrolled away. It now forgets that when the style
changes, so the view looks like one built in the iOS style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
openSettingsDestination runs from a tile's onPressed and looked up the
split view's list scope, the theme and the style scope with calls that
make the tile depend on them, outside of a build. It now reads them
without listening, as it already read the page trail.

Nothing observable changes, so there is no new test: the tests that open
pages from tiles, in lists and in split views, cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…S.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yadaniyil
yadaniyil merged commit 6b15993 into master Oct 8, 2026
2 checks passed
@yadaniyil
yadaniyil deleted the refactor/simplify-lib branch October 8, 2026 20:34
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.

1 participant