docs(agents): document surfaces, typography and the lib/ui layer
This commit is contained in:
+456
-74
@@ -10,8 +10,15 @@ Android lib mode:
|
|||||||
classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its
|
classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its
|
||||||
classes and libraries (see Android Core Build).
|
classes and libraries (see Android Core Build).
|
||||||
- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter
|
- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter
|
||||||
crosses the `${packageName}/service` MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin`
|
crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core
|
||||||
rather than calling the Core directly.
|
directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method
|
||||||
|
calls.
|
||||||
|
- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so
|
||||||
|
main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;
|
||||||
|
a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background
|
||||||
|
task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on
|
||||||
|
the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and
|
||||||
|
`NetworkObserveModule` refreshes DNS on its own looper.
|
||||||
- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,
|
- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,
|
||||||
initializes and synchronizes Android shared state, and closes the native service path exactly once.
|
initializes and synchronizes Android shared state, and closes the native service path exactly once.
|
||||||
- Because Core is in the application process on Android, application RSS already includes Core memory.
|
- Because Core is in the application process on Android, application RSS already includes Core memory.
|
||||||
@@ -87,16 +94,28 @@ into `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use
|
|||||||
section reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops
|
section reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops
|
||||||
it on Android, where the seccomp policy kills the process for `settimeofday`.
|
it on Android, where the seccomp policy kills the process for `settimeofday`.
|
||||||
|
|
||||||
A custom profile's TUN page is the exception to covering the raw struct. The network settings own `tun` through
|
TUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,
|
||||||
`PatchClashConfig.tun`, spread last over the profile's section and patched again on every `updateConfig`, so
|
`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every
|
||||||
`TunOverrideKey` offers only a few keys outside it that work in FlClash's TUN: `disable-icmp-forwarding`, and
|
`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide
|
||||||
`exclude-interface`, which only Linux routing reads. `test/models/tun_override_test.dart` fails if a key comes to
|
(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`
|
||||||
overlap `Tun`. Android starts its VPN from `core/tun` without the section, so TUN is listed on desktop only.
|
rows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,
|
||||||
Being a part of the section, the page has no quick edit; see Quick Edit.
|
congestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless
|
||||||
|
picked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move
|
||||||
|
carries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task
|
||||||
|
appends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes
|
||||||
|
`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at
|
||||||
|
that value. A key sing-tun reads on some platforms only is listed only there
|
||||||
|
(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port
|
||||||
|
filters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their
|
||||||
|
descriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.
|
||||||
|
`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from
|
||||||
|
`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick
|
||||||
|
edit; see Quick Edit.
|
||||||
|
|
||||||
A custom profile's overview keeps tiles for rules, providers and DNS, which every custom profile runs on, and puts the
|
A custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the
|
||||||
dialer proxies, NTP, sniffer and TUN behind a single "more" tile, which opens a page listing them with their states.
|
dialer proxies, NTP, sniffer and TUN behind a single "more" row, which opens a page listing them with their states.
|
||||||
Another rarely changed section joins that list rather than taking a tile of its own.
|
Both lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins
|
||||||
|
the "more" list rather than taking a row of its own.
|
||||||
|
|
||||||
Every page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the
|
Every page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the
|
||||||
section's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the
|
section's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the
|
||||||
@@ -151,7 +170,8 @@ or a remote party reaching the privileged Core's control or filesystem APIs is a
|
|||||||
|
|
||||||
A process running as the same account is not stopped. It can ask the Helper to start the Core
|
A process running as the same account is not stopped. It can ask the Helper to start the Core
|
||||||
against a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives
|
against a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives
|
||||||
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig` reads any path.
|
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,
|
||||||
|
`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.
|
||||||
The Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot
|
The Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot
|
||||||
distinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root
|
distinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root
|
||||||
or user-to-SYSTEM privilege escalation, not a complete security fix. An open external
|
or user-to-SYSTEM privilege escalation, not a complete security fix. An open external
|
||||||
@@ -319,6 +339,20 @@ directions. The envelope is the only JSON serialization layer: keep arguments, r
|
|||||||
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
|
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
|
||||||
remain strings.
|
remain strings.
|
||||||
|
|
||||||
|
A response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`
|
||||||
|
(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a
|
||||||
|
direct buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only
|
||||||
|
from that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.
|
||||||
|
|
||||||
|
A profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without
|
||||||
|
`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.
|
||||||
|
`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`
|
||||||
|
puts the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`
|
||||||
|
for the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a
|
||||||
|
profile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`
|
||||||
|
merges into the spill directory, and the extend page only the names
|
||||||
|
and types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.
|
||||||
|
|
||||||
Go event delivery is intentionally non-blocking:
|
Go event delivery is intentionally non-blocking:
|
||||||
|
|
||||||
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
|
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
|
||||||
@@ -346,9 +380,15 @@ Dialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo
|
|||||||
back to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is
|
back to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is
|
||||||
already dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every
|
already dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every
|
||||||
three seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom
|
three seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom
|
||||||
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an include-all that no
|
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`
|
||||||
filter narrows; what filters and providers pick, including provider proxies that carry their own `dialer-proxy`, is
|
that no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies
|
||||||
left to this guard.
|
that carry their own `dialer-proxy`, is left to this guard.
|
||||||
|
|
||||||
|
`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so
|
||||||
|
import and quick edit keep it. They take in only what the config declares, which is what some group of the profile
|
||||||
|
names: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other
|
||||||
|
proxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by
|
||||||
|
v0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.
|
||||||
|
|
||||||
Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate
|
Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate
|
||||||
listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
|
listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
|
||||||
@@ -376,8 +416,8 @@ holds the group; `delayTestPageGroup` marks it, ignores a second request for the
|
|||||||
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
|
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
|
||||||
|
|
||||||
The dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,
|
The dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,
|
||||||
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds its check and the
|
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each
|
||||||
exit IP beside it, and `NetworkDetection` holds the exit IP, whose tap to retry stays inert meanwhile.
|
tile, and `CurrentOutboundCard` holds the exit IP, whose refresh button stays inert meanwhile.
|
||||||
`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the
|
`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the
|
||||||
probe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;
|
probe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;
|
||||||
without the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting
|
without the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting
|
||||||
@@ -423,9 +463,34 @@ a new IP and an unchanged IP reuses the result. A lookup races its sources the w
|
|||||||
first one that determines a type. A success is kept for the session with `ref.keepAlive()`; a failure is not cached,
|
first one that determines a type. A success is kept for the session with `ref.keepAlive()`; a failure is not cached,
|
||||||
and Riverpod's automatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.
|
and Riverpod's automatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.
|
||||||
|
|
||||||
|
## UI Layer
|
||||||
|
|
||||||
|
`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the
|
||||||
|
scaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from
|
||||||
|
them that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.
|
||||||
|
`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.
|
||||||
|
|
||||||
|
`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave
|
||||||
|
`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none
|
||||||
|
may import Riverpod. A component that needs app state reads what the app provides above its navigator:
|
||||||
|
|
||||||
|
- `ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls
|
||||||
|
over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,
|
||||||
|
`isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild
|
||||||
|
when another changes; `context.isMobileView` reads it without listening.
|
||||||
|
- `MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.
|
||||||
|
- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and
|
||||||
|
the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the
|
||||||
|
scale changes; a global copy or a top-level final would keep the scale it was first read at.
|
||||||
|
- `UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`
|
||||||
|
in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.
|
||||||
|
|
||||||
|
A test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from
|
||||||
|
`test/helpers/test_app.dart` in its builder, as `TestApp` does.
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
|
`lib/ui/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
|
||||||
`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and
|
`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and
|
||||||
can animate its shape.
|
can animate its shape.
|
||||||
A `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and
|
A `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and
|
||||||
@@ -451,8 +516,8 @@ some other state, like the sidebar toggle that tracks the pane's width, is a bui
|
|||||||
through its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the
|
through its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the
|
||||||
reference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.
|
reference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.
|
||||||
|
|
||||||
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, `FilledButton`
|
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and
|
||||||
in its tonal and icon forms, and floating action buttons draw their glyph with `fill: 1`, because an outline on a tinted
|
`FilledButton` in its tonal and icon forms draw their glyph with `fill: 1`, because an outline on a tinted
|
||||||
container reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes
|
container reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes
|
||||||
`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every
|
`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every
|
||||||
`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks
|
`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks
|
||||||
@@ -464,7 +529,7 @@ Framework widgets that draw their own icon take it from the theme: the `ActionIc
|
|||||||
supplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`
|
supplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`
|
||||||
is given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.
|
is given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.
|
||||||
|
|
||||||
Empty states draw no image assets either. `NullStatus` in `lib/widgets/null_status.dart` shows the glyph for its
|
Empty states draw no image assets either. `NullStatus` in `lib/ui/null_status.dart` shows the glyph for its
|
||||||
meaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with
|
meaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with
|
||||||
one of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the
|
one of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the
|
||||||
accents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave
|
accents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave
|
||||||
@@ -482,18 +547,227 @@ button while searching) when there is one, then foldable actions, primary action
|
|||||||
the icon actions ahead of it;
|
the icon actions ahead of it;
|
||||||
everything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a
|
everything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a
|
||||||
disabled item. The bar reads as one button group of two beside a single button: the overflow menu button when
|
disabled item. The bar reads as one button group of two beside a single button: the overflow menu button when
|
||||||
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu. `primaryAction` counts only where the bar holds it, in a bottom sheet and on a phone's home
|
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.
|
||||||
page, whose foot the navigation dock takes; elsewhere it is the page's floating action button. No page adds a
|
The bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in
|
||||||
floating action button of its own: a secondary action such as selecting every app goes into `menuItems`, and saving
|
the app floats an action button: a secondary action such as selecting every app goes into `menuItems`. Saving or
|
||||||
an edit page is a check among its `iconActions`. Actions on a multi-selection, such as delete and select all, go into
|
confirming, in a page, a side sheet or a form, is a check in `confirmationAction`, never among `iconActions`: it ends
|
||||||
|
the bar and never folds, and only a form sheet moves it to its foot (Forms). Bar buttons are glyphs with tooltips,
|
||||||
|
select all included; the one text button is the custom profile's Import, a filled button whose `CommonPopupBox` opens
|
||||||
|
the import menu. Actions on a multi-selection, such as delete and select all, go into
|
||||||
`selectionActions` instead: the bar keeps them in sight as one button group that takes a single slot beside the search
|
`selectionActions` instead: the bar keeps them in sight as one button group that takes a single slot beside the search
|
||||||
or clear button, so they never fold.
|
or clear button, so they never fold. A sheet's close button leads its bar when the bar has actions, except beside a
|
||||||
|
primary action: then the two end the bar as one button group, as the proxies sheet's delay test and close do.
|
||||||
|
|
||||||
|
Actions on what the page edits as a whole, such as renaming the custom profile or viewing its final config, go into
|
||||||
|
`titleMenuItems` rather than the bar: the title shows a chevron and opens them, as an iOS document's title menu does.
|
||||||
|
The menu opens from the title's start edge (`PopupPlacement.overAnchorStart`), since the bar's own menus, which open
|
||||||
|
from their end edge, would cover the back button there.
|
||||||
|
|
||||||
A bar under a sheet drops its actions and keeps its title and back or close button. `CommonScaffold` fades the actions
|
A bar under a sheet drops its actions and keeps its title and back or close button. `CommonScaffold` fades the actions
|
||||||
out with the entrance of a sheet laid over its page, or over the sheet it is a page of, so none on the layer below
|
out with the entrance of a sheet laid over its page, or over the sheet it is a page of, so none on the layer below
|
||||||
reads as an action of the sheet on top; `sheetCoverOf` in `lib/widgets/sheet_navigator.dart` reads that entrance, which a sheet route hands
|
reads as an action of the sheet on top; `sheetCoverOf` in `lib/ui/sheet_navigator.dart` reads that entrance, which a sheet route hands
|
||||||
down through `SheetCoverRouteMixin`, as `SnapSheetRoute` and `ModalSideSheetRoute` do.
|
down through `SheetCoverRouteMixin`, as `SnapSheetRoute` and `ModalSideSheetRoute` do.
|
||||||
|
|
||||||
|
## Liquid Glass
|
||||||
|
|
||||||
|
`ThemeProps.liquidGlass` picks a `LiquidGlassMode`, `none` by default, and reaches widgets as `LiquidGlassScope`, on for
|
||||||
|
`standard` and `ultra` with that `LiquidGlassQuality`, from `ThemeManager`. With it on, `CommonScaffold` passes `glass`
|
||||||
|
to the bar's `TonalButtonTheme`,
|
||||||
|
which puts a `LiquidGlass` pane behind each button through `ButtonStyle.backgroundBuilder` and one behind a
|
||||||
|
`TonalButtonGroup`; the navigation dock's bar and lifted lens become panes as well, and so do a sheet's docked search,
|
||||||
|
the cards of a `CommonPopupMenu`, and a `CommonDialog`. On the dashboard only the start button is a pane: its connected
|
||||||
|
buttons and switches keep their fills, since each pane adds its area to a capture retaken whenever the dashboard
|
||||||
|
redraws, which it does often. Each still swells on its own under `ElasticPress`, and a `DashboardConnectedGroup` paints
|
||||||
|
the button pressed last over the neighbours it swells past. `lib/ui/liquid_glass.dart` holds the rest:
|
||||||
|
|
||||||
|
- A `LiquidGlassHost` over the app (`lib/ui/liquid_glass_capture.dart`, after g1455's atlas proxy) draws its panes
|
||||||
|
without reading the backdrop. As the frame is composited, before its children, it redraws onto one small image what
|
||||||
|
each pane under it would read: the layers painted before the pane, within its reach, shrunk as far as a sigma of the
|
||||||
|
pane's frost still spans two of the image's pixels, twice where g1455 records its regular finish, and never past
|
||||||
|
eight screen pixels to one, the coarsest g1455 measured, with that frost blurred in by the engine. A pane without frost, the dock's lens, keeps the screen's resolution, as g1455 keeps its clear finish.
|
||||||
|
A pane with a `haze` takes a second slot in the same image, shrunk and blurred for its far wider `hazeBlur`, and
|
||||||
|
mixes the two.
|
||||||
|
Each pane paints the shader over its part of that image as an ordinary paint, one read a sample where its frost is
|
||||||
|
even. No pane ends the frame's render pass or copies the surface, and the glass never trails the content by a frame.
|
||||||
|
A pane outside any host draws only its tint and rim.
|
||||||
|
- `ultra` draws all of the glass below. `standard` leaves out what costs most for least: a haze becomes the whole
|
||||||
|
frost, so the dock takes one shrunken slot in place of a sharp one at the screen's resolution, and its text no
|
||||||
|
longer shows faintly through; a lens's rim parts no colors; the lifted lens's copy goes unwarped, tinted with the
|
||||||
|
light its pane would add; and the dock casts no shadow, which is under 3/255. The host applies the style changes to
|
||||||
|
every pane it draws, the dock the rest. A config saved with `standard` before `ultra` existed moves to `ultra` once
|
||||||
|
(`Migration.upgradeVersion` 2), since that was its look. On the SwiftShader emulator, as raster time a frame over
|
||||||
|
glass off, median of four alternating runs, the glass before the tiers, `ultra` and `standard` added 16.4, 13.5 and
|
||||||
|
9.5 ms scrolling, 52, 40 and 17 dragging the lens, 34, 26 and 18 scrolling under an open menu, 22, 13 and 11
|
||||||
|
opening it and 17, 9 and 5 closing it; scrolling took 18.8 render passes a frame in either tier against 23.6, and
|
||||||
|
the lens 22 in `ultra` and 18 in `standard` against 30.
|
||||||
|
- A pane over one painted before it in the same root reads a second image, a level up, with the first drawn in as
|
||||||
|
glass. Over means within 2.5 sigmas of its frost and as far as it bends, where a pane takes under 1% of what it
|
||||||
|
reads, rather than the 3 sigmas and 2 dp its slot holds: the bar's buttons stand 8 dp apart, inside the slot of the
|
||||||
|
overflow button, which went up a level and cost a second image every frame content moved under the bar. A pane's
|
||||||
|
root is the host or its nearest ancestor that draws offscreen: as a backdrop filter would there, it
|
||||||
|
sees only that ancestor's content, without the ancestor's own effect, and replaces what it covers. A
|
||||||
|
`LiquidGlassFade` is no root: as Core Animation fades a layer, its panes still read what lies behind it and fade with
|
||||||
|
the rest. A level keeps its image while what it drew, the same pictures under the same transforms, clips and
|
||||||
|
effects, stays the same, so a still screen redraws its glass without a capture and content scrolling under glass
|
||||||
|
captures once a frame. Within an image each band of slots that blur alike keeps its pixels the same way, copied
|
||||||
|
from the last image rather than drawn and blurred again, and a slot without frost is a band of its own: dragging
|
||||||
|
the lens redraws only its copy's slot, not the dock's haze or the buttons' frost. Not on Skia: an image there keeps
|
||||||
|
what it was drawn from as long as it lives, so each capture copied from the last would hold every one before it,
|
||||||
|
and memory grows by a capture a frame. The dark line along the rim darkens the blurred capture there rather than the
|
||||||
|
sharp backdrop, a pixel or two wide.
|
||||||
|
- Measured on an Android emulator rendering in software (SwiftShader, 1080×2400 at density 420), as raster-thread time a
|
||||||
|
frame counting the captures that FrameTiming's raster span leaves out, median of three runs, each capture against
|
||||||
|
none in a run of its own: scrolling Tools takes 43 ms with none, 6 more with a sigma over one pixel of the capture and
|
||||||
|
11 over three; scrolling the dashboard 64, 9 and 19; switching to Proxies 34, 15 and 25; opening a menu 42, 9 and 13;
|
||||||
|
closing it 41, 1 and 3; dragging the dock's lens 39, 38 and 42. One pixel adds about half what three do while
|
||||||
|
content scrolls, and about as much while a page switches, where the capture's size is not what costs, or while the
|
||||||
|
lens moves, which both capture at full resolution. Three was measured before a menu's capture was held to eight
|
||||||
|
screen pixels to one, and two, which glass uses, has not been timed. Ultra, which read the backdrop under each pane,
|
||||||
|
added 27 to 61 ms to the same frames and was dropped. A software renderer weighs passes and texture reads unlike a
|
||||||
|
phone's GPU, so the figures rank the captures rather than price them; no phone has measured them. A still screen
|
||||||
|
draws no frames.
|
||||||
|
- Rendered under `flutter test --enable-impeller` at density 3, against a capture with a sigma over three pixels, one
|
||||||
|
pixel blurs text under the dark dock into blocks that set 16% of its pixels apart side by side (CIEDE2000 above 2),
|
||||||
|
and two sets 0.5% apart. Reading a coarse capture through a bicubic filter barely helps: the blocks are in the
|
||||||
|
capture.
|
||||||
|
- Where a pane's frost is uneven, as along a rim that softens, the shader reads what the capture does not hold as the
|
||||||
|
engine's Gaussian, in device pixels, on a Vogel disc of 16 taps up to a sigma of 2 pixels, 32 up to 3.5 and 64
|
||||||
|
past it, so no taps lie further apart than 64 leave them at the widest; rendered at density 2.625 the lens moves by
|
||||||
|
at most 12/255 on a few pixels from 64 taps throughout.
|
||||||
|
- Glass builds no pipelines ahead of use. A warm-up that rendered panes offscreen once the first frame landed was
|
||||||
|
dropped: after an install, with no pipeline cache on disk, it compiled on the raster thread just as the app was first
|
||||||
|
used, to spare the first menu and lens only a few pipelines.
|
||||||
|
- A pane is a rounded rectangle, each corner with its own radius, unless a `clipper` gives it a path, as the start
|
||||||
|
button's cookie does. The shader reads that path as its reach from the pane's center in 64 directions, so it must
|
||||||
|
meet every ray from the center once.
|
||||||
|
- Without Impeller's shader filters (Skia on the Intel macOS build, `flutter test`) a pane frosts from its capture:
|
||||||
|
blur, saturation, brightness, tint, and a rim, with no refraction; the tint is not thickened there, as it is where a
|
||||||
|
pane has no capture, since the capture holds the blur. `flutter test --enable-impeller` runs the shader, so `toImage`
|
||||||
|
shows the real glass.
|
||||||
|
- A popup grows out of its anchor on a platter (`CommonPopupRoute`). On glass the platter is a `LiquidGlassPane`
|
||||||
|
easing from the button's `regular` glass to the thicker `menu` glass, and the menu's cards stand `bare` while it
|
||||||
|
shows, opening and returning, so one pane bends the backdrop at a time. The platter takes the `menu` glass's frost
|
||||||
|
from the start and, as the cards do, paints with a `hold` over all it may sweep: the host captures that once and
|
||||||
|
keeps it while the pane moves inside, its frost neither grows nor falls by a tenth, as a folding sub menu's glass
|
||||||
|
does, and the page holds still, so a menu opening captures three times and closing twice rather than every frame.
|
||||||
|
On the SwiftShader emulator that cut a menu's frames from 17 render passes to 12.4, against 11.1 without glass, but
|
||||||
|
saved time only within noise: what glass still adds there, about half again a frame, is the shader over the
|
||||||
|
growing platter. A held pane casts its shadow after itself, over its capture rather than in it, since a shadow moving with it would
|
||||||
|
renew the capture. The anchor's snapshot is taken under `LiquidGlass.withoutPanes`: offscreen a pane has no
|
||||||
|
backdrop and would come out a flat tint. A menu opened at a point fades out under the layer a `LiquidGlassFade`
|
||||||
|
pushes, so its glass keeps reading the page as it goes.
|
||||||
|
- An alert on glass takes the `alert` glass, the `menu` glass frosted wider, under its `AlertDialog`'s surface, which
|
||||||
|
it finds as the dialog's `Material` lays it out, since a dialog takes only a color. Its route fades it under a
|
||||||
|
`LiquidGlassFade`, so the glass keeps its full blur and fades with the text, as UIKit fades an alert and its glass on
|
||||||
|
one layer. Its plain buttons and fields fill with `glassFillOf`, black or in dark mode white at 13%: iOS 27 shifts
|
||||||
|
them from the glass under them by about 30/255, darker or lighter, a blend Flutter has no mode for, which 13% meets
|
||||||
|
over the glass an alert shows on a page. Its route dims by `alertScrim`, iOS's fifth in light mode and near half in
|
||||||
|
dark mode, not Material's third, so the glass reads the backdrop iOS's reads; under Material's a light alert came
|
||||||
|
out grey. Measured against UIKit over the same page, the title sits 21 dp down, a message 6 dp under it and a field
|
||||||
|
16, the buttons 20 under either; plain button labels take the medium weight, and a light message takes
|
||||||
|
`onSurfaceVariant` at 85%, as light as iOS's vibrant secondary label. UIKit sets a title with no message centred in
|
||||||
|
the body weight, so an alert with fields (`hasFields`) does too, with room for a trailing control's width kept on
|
||||||
|
both sides to hold the title on the alert's centre. A destructive action takes `ColorScheme.destructive`, iOS's
|
||||||
|
systemRed: Material's `error` read dull in light mode and pink at tone 80 on dark glass.
|
||||||
|
- A press anywhere on an alert swells the whole alert, glass and content, as UIKit swells it, measured at 4 pt on its
|
||||||
|
320 width, and a drag past its edges stretches it about 3 pt; the button under the finger does not swell, it fades
|
||||||
|
its fill to 40%, or a dark default button's to 50%, while a dark plain button keeps its fill. `ElasticPress` takes
|
||||||
|
the swell and the pull in dp and finds the surface through `panelOf`, since an `AlertDialog` spans the screen
|
||||||
|
around it.
|
||||||
|
- The `menu` and `alert` glass follow iOS 27's menus and alerts as the simulator showed them over solid, striped and
|
||||||
|
split grounds, keeping the scheme's tint: in light mode the glass passes the backdrop at 45%, doubled in saturation
|
||||||
|
and a tenth brighter, within 2/255 of iOS; in dark mode iOS keeps each colour's hue and chroma while it compresses
|
||||||
|
white to mid grey and lifts black to 32/255, which the shader matches over solid grounds, within 4/255 and 11 at
|
||||||
|
worst, by thickening the scheme's highest container over a light backdrop. The dark fit weighs black and dark grey
|
||||||
|
most, the grounds a dark page puts behind it. That `contrast` reads the whole pane's backdrop, not each pixel, so
|
||||||
|
over half black, half white the white half shows lighter than iOS's. It reads five points, each as four taps a frost
|
||||||
|
sigma apart so that a glyph passing under one does not flicker the tint, but once under a haze or a frost of 8 dp
|
||||||
|
or more, which blur a glyph past flickering. iOS frosts an alert at a sigma of about 13 dp
|
||||||
|
and a menu at 10 to 13, with no sharp share like the dock's.
|
||||||
|
- The `menu` glass leaves its rim to a `LiquidGlassRim`, which alerts, menu cards and the platter as it lands draw
|
||||||
|
around their panes: the line on the canvas outside the pane, and the highlight in the pane's shader, band by band
|
||||||
|
and turn by turn as the fallback draws it in nine even-odd rings, within 12/255 of those. It is shaded unlike the
|
||||||
|
bars' rim: as iOS 27 draws it around both, measured in the simulator, the dark
|
||||||
|
line lies just outside the glass, darkest along the sides, and the highlight is light added to the glass, brightest
|
||||||
|
at the middle of the top and bottom, about half as bright where a corner begins, a fifth where it ends and gone
|
||||||
|
down the sides, fading over about 5 dp inward; a menu's line is lighter than an alert's. A glass menu and a glass alert cast iOS 27's shadow as
|
||||||
|
measured over white, a deep one under a wide one, half again as dark in dark mode, where a solid menu keeps its
|
||||||
|
softer one.
|
||||||
|
- The optics bend with a circular bevel whose width and depth shrink to fit a small pane, so the rim shows the content
|
||||||
|
squeezed and mirrored. The materials are iOS 27's as codenameone/CodenameOne#5906 fitted them (`pill` for the dock,
|
||||||
|
`regular` for buttons): the backdrop passes through as `offset + scale * saturated backdrop`, the tint at
|
||||||
|
`1 - scale` carrying the offset; a dark line takes the outermost pixels because UIKit draws it just outside the
|
||||||
|
bounds, on the dock as faint all around as iOS draws it along the top and bottom, since the darker sides iOS draws
|
||||||
|
read as brackets on a pale page; a highlight lights the top and bottom, fading over 1.3 dp and, as iOS 27's tab bar
|
||||||
|
fades it around its rounded ends, with the square of the normal's upward part; the lifted lens keeps a narrower band.
|
||||||
|
Buttons (`regular`, `tinted`) take the rim of iOS 27's glass and bar buttons, measured in the simulator: the line
|
||||||
|
darkens toward the sides with the cube of the normal's sideways part, from nothing along a dark button's top and
|
||||||
|
bottom, and the highlight fades over 1.2 dp with a sixth of it in a tail to 4.5 dp, dims round the ends far faster
|
||||||
|
than the tab bar's, and is partly added as light, so it lifts a pale button as iOS does.
|
||||||
|
The line and the highlight keep to the superellipse the pane is clipped to, which the shader traces after the
|
||||||
|
engine's `round_superellipse_param.cc`: the circles the optics bend by lie up to half a dp outside it where a long
|
||||||
|
side runs into its end, and a line on them breaks there. A test holds the traced curve to the engine's path.
|
||||||
|
In dark mode the tint is
|
||||||
|
the scheme's `surfaceContainerHighest` at iOS's alpha: iOS's mid grey lifts a dark page into a grey haze. The tint
|
||||||
|
thickens over content much darker than it, or with a negative `contrast` much lighter.
|
||||||
|
- The `pill`'s frost, line and shadow follow iOS 27's tab bar at rest, measured in the simulator over solid fills and a
|
||||||
|
black and white split: 28% of the backdrop shows sharp and 72% through a blur of 15.6 dp, so a stripe under the bar
|
||||||
|
spreads over its neighbours; iOS mixes the two brighter toward white, as if it blurred in linear light, which the
|
||||||
|
engine's blur cannot. Dark mode draws no line, as iOS draws none along a dark bar's top and bottom. Glass bars cast
|
||||||
|
1.3% black blurred 3.8 dp and dropped 6.5 dp, shading only below and by 3/255 at most, in both modes; the solid
|
||||||
|
dock keeps its wider shadow. The sharp part needs no blur pass of its own, so on the SwiftShader emulator the haze
|
||||||
|
added half a render pass a frame scrolling Tools, about four on the dashboard, where it no longer shares the start
|
||||||
|
button's blur, and no time beyond the noise of three alternating runs. Its colors are deliberately not iOS's: iOS's
|
||||||
|
bar takes no `contrast`, its selection platter is a color matrix of the bar and its icons are vibrant, where the
|
||||||
|
dock keeps the theme's.
|
||||||
|
- The dock and its lens follow UIKit's tab bar as codenameone/CodenameOne#5906 measured it frame by frame: 86 dp a
|
||||||
|
destination, a resting lens 8 dp wider, a lift of 16 dp that magnifies what lies inside the lens by 16%, and springs
|
||||||
|
taken from the capture; the lens's stretch and squash are a fit to its deformation tables. The lift stays up until it
|
||||||
|
has risen fully and the lens has arrived, so a tap pops it all the way, and `ElasticPress` likewise finishes a tap's
|
||||||
|
swell on the bar's press spring before it bounces back. The destinations stand on the lifted lens, as iOS draws
|
||||||
|
them, so it bends only the bar; what lies inside it is copied, tinted and magnified, into an offscreen layer where
|
||||||
|
a second clear pane warps it toward the rim on its own, as liquid_prism read UIKit's displacement inputs on iOS 27.
|
||||||
|
The lens follows what iOS 27 shows of it over ramp and line backdrops in the simulator: it reads outward along its
|
||||||
|
normal all around, by nothing at the rim and 4.3 dp a 5.5 dp bevel in, easing off toward its middle, so a band
|
||||||
|
rings it with what lies right under its rim, drawn out; red and blue part by up to 6.5 dp along its top and bottom
|
||||||
|
and about 3.9 dp around its ends, across the band's outer 7 dp. In dark mode both panes brighten what they show by
|
||||||
|
a third, as iOS does, and the lens casts a soft shadow 8 dp down. A press lights the bar through `LiquidGlass.glow`:
|
||||||
|
a wash over the whole pane, brightest at the finger, that spreads out as it fades. A `pressed` pane, as a glass
|
||||||
|
button's or a `TonalButtonGroup`'s is, hears the pointer on what it wraps and lights from the finger the same way.
|
||||||
|
|
||||||
|
## Popup Motion
|
||||||
|
|
||||||
|
`CommonPopupRoute` follows WWDC25's account of iOS 26 menus: one that grows out of a control keeps the two continuous,
|
||||||
|
so closing returns it into the control on the same platter, its content blurring out first. Apple documents only the
|
||||||
|
opening; one opened at a point (a context menu) has no control to return into and shrinks toward the point to
|
||||||
|
`_dismissScale` as it fades, as iOS context menus do.
|
||||||
|
|
||||||
|
A sub menu is a `PopupMenuCard` laid out once at full size. One spring (`_unfold` in `lib/ui/popup.dart`) moves
|
||||||
|
its surface and clip from the item that opened it to its bounds, recedes the parent, and reveals the items, so a fold
|
||||||
|
caught mid-unfold turns back with the speed it had. The parent's item hides under the sub menu's header, which draws
|
||||||
|
it again, and the sub menu is removed only once it stands on that item.
|
||||||
|
|
||||||
|
A `CommonPopupBox` opens on its button's pointer down, as an iOS menu that is a button's primary action does, and the
|
||||||
|
press carries on into the menu. `_PopupPressRecognizer` claims the pointer then, so the button's own tap and its
|
||||||
|
tooltip's long press stand down. In a scroll view, as the settings rows are, it first waits `kPressTimeout`, as iOS
|
||||||
|
scroll views hold back a touch, so a drag from the button scrolls and a quicker tap opens the popup on its release. The
|
||||||
|
pointer's moves and release keep reaching the button, so the box hands them on (`_CarriedPress`) and lets go of the
|
||||||
|
`ElasticPress` around it. The menu follows them only once they leave the slop, since it covers the button and a press
|
||||||
|
let go where it went down would pick what now lies under it, and drives its own `ElasticPress` with them through
|
||||||
|
`ElasticPress.maybeOf`. The box tells a disabled button by the `onTap` of its first `InkResponse`, and holds the popup's
|
||||||
|
`anchorShift`, since it opens before the button's `onPressed` would run.
|
||||||
|
|
||||||
|
The menu stands in an `ElasticPress` of its own with `pullPastEdges`: it swells under a press and stretches only toward
|
||||||
|
a finger that strays past it, so sliding over the items or scrolling leaves it in place. A press on a row belongs to the
|
||||||
|
menu rather than the row's `InkWell`, which keeps only hover, focus, and keys: `_RowPressRecognizer` highlights the row
|
||||||
|
under the finger as it slides, hit testing where the menu paints (`ElasticPress.paintBoundsOf`), with a selection click
|
||||||
|
on each change, and picks the one it is let go on, or none off the rows. It claims the pointer only on release, so a
|
||||||
|
menu long enough to scroll takes a drag first and drops the press. A press that rests for `_dwellDuration` on an item
|
||||||
|
with a sub menu opens it, and one that rests on an open sub menu's header folds it back, each with a click as the
|
||||||
|
highlight moves; the finger then stands on the header or on the item it folded into, and letting go there changes
|
||||||
|
nothing. A tap that opens or folds a sub menu clicks no more than a tap on any item does, as iOS menus give selection
|
||||||
|
feedback only to a sliding press.
|
||||||
|
|
||||||
## Row Actions And Sort Mode
|
## Row Actions And Sort Mode
|
||||||
|
|
||||||
A list row or card carries no trailing more button. Its secondary actions go into a `ContextMenuRegion`, which opens
|
A list row or card carries no trailing more button. Its secondary actions go into a `ContextMenuRegion`, which opens
|
||||||
@@ -536,9 +810,9 @@ few keys of a `tun` section the network settings own, so its spec does not mix i
|
|||||||
## Where A Route Opens
|
## Where A Route Opens
|
||||||
|
|
||||||
On desktop each navigation destination has its own `Navigator` (`_NavigationPage` in `lib/pages/home.dart`); on mobile
|
On desktop each navigation destination has its own `Navigator` (`_NavigationPage` in `lib/pages/home.dart`); on mobile
|
||||||
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/common/navigator.dart`. A route
|
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/ui/navigator.dart`. A route
|
||||||
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet` and `showSnapSheet`
|
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet`, `showFormSheet` and
|
||||||
all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
|
`showSnapSheet` all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
|
||||||
stays in that destination's stack when the user switches away and back. `sheetNavigatorOf` lifts a sheet out of a
|
stays in that destination's stack when the user switches away and back. `sheetNavigatorOf` lifts a sheet out of a
|
||||||
paged sheet's own `SheetPagesNavigator`, never past the destination.
|
paged sheet's own `SheetPagesNavigator`, never past the destination.
|
||||||
|
|
||||||
@@ -570,14 +844,50 @@ rebuilds one row instead of the whole section. Titles and subtitles are `ConfigL
|
|||||||
|
|
||||||
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_networkToggle`
|
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_networkToggle`
|
||||||
in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/general.dart`. Declare a named
|
in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/general.dart`. Declare a named
|
||||||
`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by
|
`ConsumerWidget` only when a row is genuinely reused across screens. Rows with bespoke behaviour — a custom dialog, a derived value, or a
|
||||||
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
|
|
||||||
second provider write — stay hand-written rather than growing extra parameters on the shared items.
|
second provider write — stay hand-written rather than growing extra parameters on the shared items.
|
||||||
|
|
||||||
In an inset grouped list (`generateSectionV3`, `generateAnimatedSection`), a row's subtitle shows its value or state,
|
In an inset grouped list (`generateSection`, `generateAnimatedSection`), a row's subtitle shows its value or state,
|
||||||
never what the setting does. As iOS settings do, the explanation goes in the group's `footer`, drawn by `ListFooter`,
|
never what the setting does. As iOS settings do, the explanation goes in the group's `footer`, drawn by `ListFooter`,
|
||||||
one sentence per setting that names it, and a section header carries only its title. Flat `generateSection` lists keep
|
one sentence per setting that names it. A section header carries its title and at most a few actions on the group, as
|
||||||
their descriptions on the row.
|
`IconButtonData`: `ListHeader` draws them as 36 dp tonal buttons (`TonalButtonSize.section`), the first two grouped,
|
||||||
|
centered on the title. Every header stands as tall as one with a button, 6 dp clear of the group below, so headers
|
||||||
|
match whether or not they carry actions. A group with a selection swaps them
|
||||||
|
for delete and select all, never a text button. `generateSection` is the only section builder, and a sheet lays its groups inside the 16 dp inset the pages use.
|
||||||
|
|
||||||
|
A row that opens a page, `ListItem.open` or `ListItem.next`, ends in `AppGlyphs.chevronForward` unless it passes its own
|
||||||
|
`trailing`, as iOS settings rows do. A row that opens a dialog in place, `ListItem.input`, has no chevron. A setting
|
||||||
|
that takes one of a few values is an iOS pop-up row, `ListItem.options` or `ConfigOptionsItem`: it ends in its value and
|
||||||
|
`AppGlyphs.picker`, and a tap anywhere on it opens a `CommonPopupMenu` out of that value with the current option
|
||||||
|
checked. Its items are text alone, as an iOS picker's are; glyphs belong to menus of actions. One value is never picked
|
||||||
|
from a radio dialog or a row of selectable chips; a button that picks one, as the theme's scheme variant does, opens the
|
||||||
|
same menu through a `CommonPopupBox`. A pick from a menu that returns into its control runs only once the popup has
|
||||||
|
landed, so a setting that rebuilds the app, such as the language, never does so under the returning platter; the
|
||||||
|
control shows its value with `PopupValueText`, which blurs across to the new value while the control eases to its
|
||||||
|
width. A menu opened at a point, as a context menu or the editor's is, runs its pick at once. A menu capped by
|
||||||
|
`maxVisibleItems`, such as the proxy group tabs' more menu with every group in it, builds only the rows in view and
|
||||||
|
takes its width from its labels measured as `EmojiText` lays them out.
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
A form that edits more than one value opens with `showFormSheet` (`lib/ui/sheet.dart`): a bottom sheet in the mobile
|
||||||
|
layout, and elsewhere a card `FormSheetRoute` centers over the navigator, as iPad and macOS present a form sheet, moving
|
||||||
|
as an alert does. Both give the page `SheetType.bottomSheet` or `SheetType.formSheet`, compact sheets that share
|
||||||
|
`CommonScaffold`'s sheet bar, so one page serves both: `FormPage` for a plain form, or the page a `NestedPagedSheet`
|
||||||
|
pushes for the custom profile editors. Its values sit in `FormRow`s inside `generateSection` groups, each field a
|
||||||
|
`FormRowTextField`, bare and set against the end in both, so a focused field's only outline is its row's focus ring.
|
||||||
|
`RenderEditable` ends a field's text short of its end by the caret's margin, so the field ends its hint as short and
|
||||||
|
reaches as far into the row's padding: the caret follows the hint or the text, and the text ends where another row's
|
||||||
|
value does. The
|
||||||
|
save is the scaffold's `confirmationAction`: last in a bottom sheet's bar, and in a form sheet the filled default beside
|
||||||
|
Cancel, which takes the close's place, in a row of alert buttons at the first page's foot, as SwiftUI places a
|
||||||
|
confirmation action on macOS; other actions, such as quick edit, stay in the bar. A row cannot hold an error line, so a
|
||||||
|
save the form refuses marks the row `invalid` and says why in the `ErrorBanner` above the groups, which follows each
|
||||||
|
edit from then on. A prompt for one value, such as a URL import, a rename or a password, stays an alert (`CommonDialog`,
|
||||||
|
`InputDialog`), as on every Apple platform; a key with its value, as `MapEditView` adds, is two values and a form.
|
||||||
|
An alert raised while focus is in a form sheet opens on the sheet's
|
||||||
|
navigator, centered on the card rather than on the window: `Dialogs` follows the focused route when it is an
|
||||||
|
`AlertHostRoute`, which the form sheet and every alert are.
|
||||||
|
|
||||||
## Keyboard Insets
|
## Keyboard Insets
|
||||||
|
|
||||||
@@ -587,48 +897,115 @@ which runs every one of those rebuilds inside the layout phase; in a profile tra
|
|||||||
`BUILD`.
|
`BUILD`.
|
||||||
|
|
||||||
- A page the user is not typing in holds its bottom inset through `KeyboardInsetHold`
|
- A page the user is not typing in holds its bottom inset through `KeyboardInsetHold`
|
||||||
(`lib/widgets/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
|
(`lib/ui/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
|
||||||
not, and without it every page the app keeps relaid out, and rebuilt under its `LayoutBuilder`s, with each frame of a
|
not, and without it every page the app keeps relaid out, and rebuilt under its `LayoutBuilder`s, with each frame of a
|
||||||
keyboard raised by a dialog or sheet on top. The home route, each home tab, `CommonRoute`, `CommonDesktopRoute` and
|
keyboard raised by a dialog or sheet on top. The home route, each home tab, `CommonRoute`, `CommonDesktopRoute` and
|
||||||
the open-container route wrap their page in one; a new page route type does the same.
|
the open-container route wrap their page in one; a new page route type does the same.
|
||||||
- A widget high in the tree reads the aspect it needs (`MediaQuery.paddingOf`, `viewInsetsOf`, ...), never
|
- A widget high in the tree reads the aspect it needs (`MediaQuery.paddingOf`, `viewInsetsOf`, ...), never
|
||||||
`MediaQuery.of` or `MediaQuery.removePadding(context: ...)` on its own context, which subscribes it to every inset
|
`MediaQuery.of` or `MediaQuery.removePadding(context: ...)` on its own context, which subscribes it to every inset
|
||||||
change. Move such a read into a small widget of its own below it, as `_AppMediaQuery` in `ThemeManager`,
|
change. Move such a read into a small widget of its own below it, as `_AppMediaQuery` in `ThemeManager`,
|
||||||
`_BodyPadding` in `lib/pages/home.dart` and `_SheetKeyboard` in `SnapSheet` do.
|
`_BodyPadding` in `lib/pages/home.dart`, `_KeyboardViewport` in `lib/ui/snap_sheet.dart` and the `SafeArea` around
|
||||||
- A bottom sheet makes room for the keyboard itself: `_SheetKeyboard` lays the content out above it and removes the
|
the docked search field in `CommonScaffold` do.
|
||||||
bottom inset from the content's `MediaQuery`, so nothing inside a sheet, `CommonScaffold` included, adds keyboard
|
- A bottom sheet makes room for the keyboard itself: the keyboard raises the sheet's foot (Bottom Sheet Motion), and
|
||||||
space. A nested sheet must not carry the keyboard through its pages: `NavigatorResizable` learns a page's new size a
|
`_WithoutKeyboard` removes the bottom inset from the content's `MediaQuery`, so nothing inside a sheet,
|
||||||
frame late, and the sheet would trail the keyboard by a frame.
|
`CommonScaffold` included, adds keyboard space. A nested sheet must not carry the keyboard through its pages:
|
||||||
|
`NavigatorResizable` learns a page's new size a frame late, and the sheet would trail the keyboard by a frame.
|
||||||
|
- The keyboard moves a bottom sheet as it is painted. `_RenderSheetViewport` lays the surface out as tall as the view
|
||||||
|
whatever the keyboard, and a fit sheet keeps the limit its short content was laid out within while the keyboard
|
||||||
|
changes the room above it, until the content reaches either (`releaseLimit`). A keyboard frame then lays out and
|
||||||
|
paints nothing inside the sheet unless what it shows changes.
|
||||||
|
- A field without a label says `floatingLabelBehavior: FloatingLabelBehavior.never`, as `FormRowTextField` and
|
||||||
|
`SearchField` do. `InputDecorator` animates the floating label of an empty field on focus even when there is none,
|
||||||
|
`InputDecoration.collapsed` included, and each frame of it lays the decorator out and, inside a sheet, the whole
|
||||||
|
form, through the first frames of the keyboard.
|
||||||
|
|
||||||
|
## Rebuild Scopes
|
||||||
|
|
||||||
|
A `LayoutBuilder` runs the rebuilds of everything under it inside its own layout, so a widget rebuilding there marks
|
||||||
|
the builder for layout, and that climbs to the nearest relayout boundary. Flutter marks every render object it lays
|
||||||
|
out for paint, so the nearest repaint boundary above that path repaints too. Under the material `Scaffold`'s body
|
||||||
|
`LayoutBuilder` (it has one with `extendBodyBehindAppBar`) that was the page and its background, and under the home
|
||||||
|
shell's `LayoutBuilder` the whole home route: a spinner turning in a page, or the dock's lens moving, relaid out and
|
||||||
|
repainted the page on every frame, and the glass over it captured every band again, the bar's included, though
|
||||||
|
nothing under the bar had changed.
|
||||||
|
|
||||||
|
So the body and the dock each take their rebuilds in a scope of their own: a `LayoutBuilder` under a repaint boundary
|
||||||
|
that is also a relayout boundary. `CommonScaffold`'s `_BodyScope` sizes itself to the most the scaffold allows, which
|
||||||
|
the scaffold never reads back, and lays the body out under it as before; `NavigationDock` sizes itself as it always
|
||||||
|
was, full width and its bar's height, and lays its boundary out tight. A rebuild inside either relays out and
|
||||||
|
repaints only that layer.
|
||||||
|
|
||||||
|
## Bottom Sheet Motion
|
||||||
|
|
||||||
|
`SnapSheetRoute` follows `UISheetPresentationController` as measured on the iOS 27 simulator (a UIKit probe app driven
|
||||||
|
by the simulator's touch injection, logging the presented view's presentation layer every frame).
|
||||||
|
|
||||||
|
- One spring moves a sheet: UIKit's mass 3, stiffness 1000, which runs critically damped (ω ≈ 18.26 rad/s, done in
|
||||||
|
about 0.5 s). Presenting, closing, a detent change and a spring back all trace it within a point. A release thrown
|
||||||
|
toward its target keeps its velocity and lands with UIKit's slight overshoot, about damping ratio 0.8 at full
|
||||||
|
strength; a release away from its target loses its velocity and lands dead.
|
||||||
|
- A release lands on the detent, or the dismissal, nearest to where `UIScrollView.DecelerationRate.fast` projects it.
|
||||||
|
With no velocity a sheet closes once it is let go past half of its shortest detent; a flick of 1000 pt/s 85 pt
|
||||||
|
down springs back on iOS, one of 1500 pt/s closes.
|
||||||
|
- The sheet follows the finger one to one below its shortest detent and stops at its tallest, with no stretch.
|
||||||
|
- On the content, a drag up grows the sheet first and scrolls the content with what is left, in one gesture. A drag
|
||||||
|
down moves the sheet only when it began with the content at its top; one that scrolls the content back to its top
|
||||||
|
stops there, as iOS bounces it rather than handing it to the sheet.
|
||||||
|
- The keyboard raises the sheet's foot: a detent keeps what it shows above the keyboard, as a custom detent does on
|
||||||
|
iOS, up to a peek below the top. iOS lifts a medium sheet to its large detent instead, which a phone's keyboard,
|
||||||
|
about as tall as the gap between them, makes nearly the same. Since the sheet's height is a function of the inset,
|
||||||
|
it moves with the keyboard on every frame and lands back where it was.
|
||||||
|
- Where the keyboard starts or stops carrying the sheet, that function rounds its corner (`_rounded`), so the sheet's
|
||||||
|
speed changes smoothly. With a sharp corner a sheet stood still while the keyboard slid over the blank below short
|
||||||
|
content and then jumped, and on the way down halted in mid-flight; the tallest detent hit the peek at the keyboard's
|
||||||
|
full speed. Short content now eases into the rise over half its blank, which can leave the sheet up to an eighth of
|
||||||
|
the blank higher than its content needs, and the tallest detent slows into the peek over as much keyboard again as
|
||||||
|
it takes to get there, up to `_peakEase`.
|
||||||
|
|
||||||
|
`_SheetModel` keeps the sheet as an activity, resting at a detent, held at a height or settling toward a detent as an
|
||||||
|
offset from it, and layout resolves the detents again every frame from the view, the keyboard and the content. A
|
||||||
|
resting or settling sheet therefore follows a page transition's size or the keyboard without being told, where a
|
||||||
|
stored height would have to be patched after each change. Below the shortest detent a drag moves the route's own
|
||||||
|
animation, so the barrier, a sheet stepped back behind this one, and the page bar under it follow the finger, and the
|
||||||
|
close continues from the release on the presentation spring (`createSimulation`).
|
||||||
|
|
||||||
## Page Arrival
|
## Page Arrival
|
||||||
|
|
||||||
A pushed page is built, laid out and painted through every frame of its transition, so whatever it starts on arrival
|
A pushed page is built, laid out and painted through every frame of its transition. Impeller has no raster cache, and
|
||||||
(loading and decoding images, building the rows its data brings in) lands inside the animation. Impeller has no
|
every offscreen pass is paid again each frame on a phone GPU. Even so, a page loads what it shows as it mounts: content
|
||||||
raster cache, and every offscreen pass is paid again each frame on a phone GPU.
|
held back until the route settles slides in as an empty page and pops in once the motion has stopped, which reads worse
|
||||||
|
than the frames a load costs. What a transition must not carry is work that repeats on every refresh or every frame.
|
||||||
|
|
||||||
- Work a widget would start as it mounts waits for the route to settle. `RouteSettledMixin`
|
- Loads start as the widget mounts, never behind `whenRouteSettled`. `CommonTargetIcon` and `PackageIcon` show what is
|
||||||
(`lib/widgets/route_motion_hold.dart`) calls `didSettleRoute` once the route that brought the page in has arrived,
|
already in memory at once and load the rest right away; the editor reads its file and mounts CodeForge as soon as the
|
||||||
and at once on a page that already sits still; `whenRouteSettled` (`lib/common/navigator.dart`) does the same for a
|
content is in. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
|
||||||
one-off future, and both wait out the frame a `HeroController` builds a pushed route offstage with its animation
|
without an image, so a grid of placeholders is never painted only to be replaced. `whenRouteSettled`
|
||||||
pinned to completed. `CommonTargetIcon` and `PackageIcon` show what is already in memory at once and load the rest
|
(`lib/ui/navigator.dart`) is left for sequencing navigation, such as opening a page once the sheet above it is gone.
|
||||||
after that. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
|
- A live feed holds its refreshes while its page moves and replays the latest once it is still, so a list does not
|
||||||
without an image, so a grid of placeholders is never painted through the transition only to be replaced.
|
re-rank, reorder or grow under a transition. `RouteMotionHoldMixin` (`lib/ui/route_motion_hold.dart`) holds them
|
||||||
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)`: the rows past the viewport would
|
while `isRouteMoving`: the page's route or a sheet carrying it is pushed, popped or dragged, a page is pushed over it,
|
||||||
otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is most of the push.
|
or a sheet covers it (`sheetCoverOf`). The connections, requests, logs and DNS pages and the dashboard's live
|
||||||
The icon grids (`IconGridScrollView`) and the access control list do this, and so does `ListEditorPage`, whose
|
connections table use it. A feed's first content is no refresh and shows at once, as `ConnectionsView` applies its
|
||||||
rows are heavy enough that only its list rebuilds as the route settles, reusing the rows already built.
|
first poll, and a held poll keeps the time it was read at, which `TrackerSpeedRanker` derives speeds from. Per-frame
|
||||||
- A page whose content waits on what it loads on arrival (lists read from the database, file sizes, the installed fonts)
|
work spread over frames pauses the same way: the font family page switches one name to its own family per frame and
|
||||||
shows its `NullStatus` illustration, with no label, inside a `NullStatusSwitcher` until all of it is in, then switches
|
stops while `isRouteMoving`.
|
||||||
to the finished content in one step, as the custom profile, resources and font family pages do. Never a skeleton
|
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)` from `RouteSettledMixin`: the rows past
|
||||||
screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds every row
|
the viewport would otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is
|
||||||
it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the dashboard's
|
most of the push. The icon grids (`IconGridScrollView`) and the access control list do this, and so does
|
||||||
service status. `holdsArrival` keeps the status a switcher opens on for at least 400 ms, about a route transition,
|
`ListEditorPage`, whose rows are heavy enough that only its list rebuilds as the route settles, reusing the rows
|
||||||
so a load that finishes just after arrival does not flash it; the connections, resources and font family pages set
|
already built.
|
||||||
it.
|
- A page whose content waits on what it loads (lists read from the database, file sizes, the installed fonts) shows
|
||||||
|
nothing until all of it is in, then the finished content in one step: `NullStatusSwitcher.isLoading`. A load that can
|
||||||
|
run long also gives the switcher a `loadingStatus`, its `NullStatus` illustration with no label, which takes the
|
||||||
|
place of a first load still running after about a route transition (400 ms); one that ends sooner goes straight to
|
||||||
|
its content, so the illustration never flashes. The custom profile, resources and font family pages set one. Never a
|
||||||
|
skeleton screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds
|
||||||
|
every row it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the
|
||||||
|
dashboard's service status.
|
||||||
- `GlyphPainter` paints a translucent glyph through an offscreen layer. A placeholder repeated in every tile blends its
|
- `GlyphPainter` paints a translucent glyph through an offscreen layer. A placeholder repeated in every tile blends its
|
||||||
color over the surface instead of carrying an alpha.
|
color over the surface instead of carrying an alpha.
|
||||||
- `PagedSheetRoute` and `CommonRoute` share
|
- `PagedSheetRoute` and `CommonRoute` share
|
||||||
`SharedXPageTransition` (`lib/widgets/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
|
`SharedXPageTransition` (`lib/ui/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
|
||||||
Android 14 slides pages, so a page moves alike in a sheet and on its own. The transition fades a page by painting the surface over it
|
Android 14 slides pages, so a page moves alike in a sheet and on its own. The transition fades a page by painting the surface over it
|
||||||
and needs no opacity layer: the page on top shows only on its side of the point where the two fades cross, opaque
|
and needs no opacity layer: the page on top shows only on its side of the point where the two fades cross, opaque
|
||||||
over the other, so neither shows through the other. A page below that does not fade with it, such as the home
|
over the other, so neither shows through the other. A page below that does not fade with it, such as the home
|
||||||
@@ -690,15 +1067,17 @@ itself live in one. Code without a `Ref` reaches providers through
|
|||||||
path that never validates never resolves the controller.
|
path that never validates never resolves the controller.
|
||||||
|
|
||||||
The UI layer must not reach a process-wide singleton directly.
|
The UI layer must not reach a process-wide singleton directly.
|
||||||
`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`,
|
`test/lint/ui_layer_singleton_test.dart` scans `lib/ui`, `lib/views`, `lib/widgets`,
|
||||||
`lib/pages` and `lib/features` for `globalState.container` and the bare
|
`lib/pages` and `lib/features` for `globalState.container` and the bare
|
||||||
`coreController` global and fails the run on either. Widgets that need Core hold
|
`coreController` global and fails the run on either. Widgets that need Core hold
|
||||||
`CoreController get _core => ref.read(coreHandlerProvider)`.
|
`CoreController get _core => ref.read(coreHandlerProvider)`.
|
||||||
|
|
||||||
`globalState.measure` and `globalState.theme` stay global on purpose. Both are
|
`globalState.measure` and `globalState.theme` stay global on purpose. Both are
|
||||||
context-derived caches assigned by `ThemeManager`, and tests already scope them by
|
context-derived caches assigned by `ThemeManager`, and tests already scope them by
|
||||||
assigning in the app builder (see `test/helpers/test_app.dart`); moving them into
|
assigning in the app builder (see `wrapAppScopes` in `test/helpers/test_app.dart`);
|
||||||
providers would touch every layout call site without changing behaviour.
|
moving them into providers would touch every layout call site without changing
|
||||||
|
behaviour. `lib/ui` cannot read `globalState`, so it takes the same `Measure` from
|
||||||
|
`MeasureScope`; see UI Layer.
|
||||||
|
|
||||||
`globalState` in `lib/state.dart` is a singleton holding ambient app state — the
|
`globalState` in `lib/state.dart` is a singleton holding ambient app state — the
|
||||||
package info, the measure and theme, the container, and the start/stop flags —
|
package info, the measure and theme, the container, and the start/stop flags —
|
||||||
@@ -707,7 +1086,7 @@ plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and
|
|||||||
window, the autostart entry, the tray and the permission prompts. Providers are
|
window, the autostart entry, the tray and the permission prompts. Providers are
|
||||||
generated into `lib/providers/generated/`.
|
generated into `lib/providers/generated/`.
|
||||||
|
|
||||||
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
|
The root navigator key lives in `lib/ui/navigator.dart` as `rootNavigatorKey`;
|
||||||
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
|
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
|
||||||
the key directly and does not import `lib/state.dart`.
|
the key directly and does not import `lib/state.dart`.
|
||||||
|
|
||||||
@@ -736,8 +1115,8 @@ The shapes that keep the closure small:
|
|||||||
- `KeyboardModifier.toHotKeyModifier()` lives in
|
- `KeyboardModifier.toHotKeyModifier()` lives in
|
||||||
`lib/manager/hotkey_manager.dart`, its only consumer, not in `lib/enum/enum.dart`.
|
`lib/manager/hotkey_manager.dart`, its only consumer, not in `lib/enum/enum.dart`.
|
||||||
- Startup orchestration lives in `lib/bootstrap.dart`, not on `GlobalState`.
|
- Startup orchestration lives in `lib/bootstrap.dart`, not on `GlobalState`.
|
||||||
`common/num.dart`, `common/print.dart` and `common/request.dart` import
|
`common/print.dart` and `common/request.dart` import `state.dart` for
|
||||||
`state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything
|
`container` and `packageInfo`/`ua`, so anything
|
||||||
`GlobalState` reaches lands in the barrel's closure; the ambient state it
|
`GlobalState` reaches lands in the barrel's closure; the ambient state it
|
||||||
holds reaches nothing platform-specific.
|
holds reaches nothing platform-specific.
|
||||||
- `SystemAction` talks to `WindowPort` and `TrayPort` from
|
- `SystemAction` talks to `WindowPort` and `TrayPort` from
|
||||||
@@ -755,9 +1134,8 @@ The shapes that keep the closure small:
|
|||||||
`providers/state/navigation.dart` reads through and `bootstrap.init` binds;
|
`providers/state/navigation.dart` reads through and `bootstrap.init` binds;
|
||||||
unbound it yields no items, so a test that renders navigation assigns
|
unbound it yields no items, so a test that renders navigation assigns
|
||||||
`navigationPort` itself, as `test/pages/home_test.dart` does. `DashboardWidget`
|
`navigationPort` itself, as `test/pages/home_test.dart` does. `DashboardWidget`
|
||||||
is plain data, since it is persisted in the app settings; the widget mapping
|
is plain data, since it is persisted in the app settings; the card mapping
|
||||||
lives in `lib/views/dashboard/widget_registry.dart`, the reverse lookup relying
|
lives in `lib/views/dashboard/widget_registry.dart`.
|
||||||
on the branches returning canonical consts.
|
|
||||||
|
|
||||||
`test/lint/platform_layering_test.dart` also pins that the barrel's closure
|
`test/lint/platform_layering_test.dart` also pins that the barrel's closure
|
||||||
contains no `lib/views` file. Data the provider layer needs from the UI layer
|
contains no `lib/views` file. Data the provider layer needs from the UI layer
|
||||||
@@ -795,7 +1173,8 @@ seen. There are three levels of gating:
|
|||||||
`SetupAction` follows it. The macOS menu bar title is the exception: it shows the traffic while the window is
|
`SetupAction` follows it. The macOS menu bar title is the exception: it shows the traffic while the window is
|
||||||
hidden. `RouteTracker` and `RoutedProbe` follow it too, as described under Route Consistency.
|
hidden. `RouteTracker` and `RoutedProbe` follow it too, as described under Route Consistency.
|
||||||
- `ActivePollingMixin` is stricter for per-widget polls such as connections and memory. It polls only while the app is
|
- `ActivePollingMixin` is stricter for per-widget polls such as connections and memory. It polls only while the app is
|
||||||
`resumed` and its home page is the current one.
|
`resumed`, its home page is the current one, and no opaque route covers its page: the overlay turns tickers off on a
|
||||||
|
covered page once the route over it has finished moving in, and the mixin follows `TickerMode`.
|
||||||
- `_NavigationPage` puts every page except the current one under a disabled `TickerMode`. Home pages are kept alive in
|
- `_NavigationPage` puts every page except the current one under a disabled `TickerMode`. Home pages are kept alive in
|
||||||
the `PageView`, and Flutter does not mute tickers on a kept-alive page off screen. A spinner left on one would
|
the `PageView`, and Flutter does not mute tickers on a kept-alive page off screen. A spinner left on one would
|
||||||
otherwise keep scheduling frames.
|
otherwise keep scheduling frames.
|
||||||
@@ -1167,6 +1546,9 @@ NDK version stands in for the hook's cache.
|
|||||||
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
|
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
|
||||||
folding, caret, gutter and pointer code sit in `lib/code_forge/code_area/` part files.
|
folding, caret, gutter and pointer code sit in `lib/code_forge/code_area/` part files.
|
||||||
|
|
||||||
|
- `loadText` builds a document of 128K characters or more on a Rust worker thread (`RopeBridge.createInBackground`),
|
||||||
|
so a large file does not stall the frame it arrives in; `EditorView` loads through it, and a later `text =` or
|
||||||
|
`loadText` supersedes one still building.
|
||||||
- Rust counts offsets in Unicode scalars and Flutter in UTF-16 code units. Convert at the boundary with
|
- Rust counts offsets in Unicode scalars and Flutter in UTF-16 code units. Convert at the boundary with
|
||||||
`text_offsets.dart`; a mixed-up offset only shows up with astral characters such as emoji, so tests use them.
|
`text_offsets.dart`; a mixed-up offset only shows up with astral characters such as emoji, so tests use them.
|
||||||
- The renderer learns what changed from `takeChange()`, whose `lines` (first line, lines removed, lines inserted) let it
|
- The renderer learns what changed from `takeChange()`, whose `lines` (first line, lines removed, lines inserted) let it
|
||||||
|
|||||||
+37
-5
@@ -51,7 +51,7 @@ hooks:
|
|||||||
```
|
```
|
||||||
|
|
||||||
CI flips `setup` to `false` with `yq` in its throwaway checkout before
|
CI flips `setup` to `false` with `yq` in its throwaway checkout before
|
||||||
`dart run tool/changelog.dart` and `flutter test`; no test loads the Go core. `rust_api`
|
`dart run tool/changelog.dart` and `dart run tool/test.dart`; no test loads the Go core. `rust_api`
|
||||||
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
|
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
|
||||||
test in `test/core/desktop/ipc_native_test.dart` load the Rust library the hook
|
test in `test/core/desktop/ipc_native_test.dart` load the Rust library the hook
|
||||||
builds; the release-notes job, which runs no tests, flips both.
|
builds; the release-notes job, which runs no tests, flips both.
|
||||||
@@ -154,12 +154,29 @@ flutter test test/setup_test.dart
|
|||||||
flutter test plugins/proxy/test/proxy_test.dart
|
flutter test plugins/proxy/test/proxy_test.dart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For the whole suite, or a directory of more than a few files, use `tool/test.dart`, which is also what CI runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run tool/test.dart # all of test/
|
||||||
|
dart run tool/test.dart test/widgets test/views # some paths
|
||||||
|
dart run tool/test.dart -- --reporter expanded --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
Plain `flutter test` compiles each file into its own program and starts an engine for it, one file after another, and
|
||||||
|
that dominates a full run: 329 files took 128 s on a 12-core Mac with at most four running at once. `tool/test.dart`
|
||||||
|
imports the files into 32 generated entrypoints under `.dart_tool/test_bundles/` and runs those with one worker per
|
||||||
|
core: about 60 s for the same run, and 67 s instead of 180–213 s with `--coverage`. Files in one entrypoint share an
|
||||||
|
isolate, so a test can see state a file before it left behind; the script runs every file that failed there again on
|
||||||
|
its own, lets that run decide, and names the files that only failed together. See the Shared Test Isolate section of
|
||||||
|
`rules.md` before fixing one.
|
||||||
|
|
||||||
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
|
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
|
||||||
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
|
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
|
||||||
run of `plugins/setup` would execute the build hook itself.
|
run of `plugins/setup` would execute the build hook itself.
|
||||||
|
|
||||||
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
|
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
|
||||||
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
|
`flutter build <platform> --debug` on every branch push, and by the release matrix on a tag; `tool/check_plugins.sh`
|
||||||
|
only analyzes and tests the Dart side.
|
||||||
|
|
||||||
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
|
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
|
||||||
|
|
||||||
@@ -338,9 +355,11 @@ directories: `plugins` (local Flutter packages and the setup build tool), `go`
|
|||||||
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
|
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
|
||||||
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
|
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
|
||||||
and the gomobile AAR built for one ABI), `rust` (both crates), and a
|
and the gomobile AAR built for one ABI), `rust` (both crates), and a
|
||||||
Windows runner for the helper's `windows-service` feature. A `desktop` job also
|
Windows runner for the helper's `windows-service` feature. On branch pushes a
|
||||||
compiles a debug build per desktop platform. Release builds run only in the
|
`desktop` job also compiles a debug build per desktop platform; a tag skips it,
|
||||||
public repository and start once every job except `desktop` passes.
|
since its release matrix compiles the same native code. Release builds run only
|
||||||
|
in the public repository and start alongside the checks; `upload` publishes
|
||||||
|
only once every build and every check job passes.
|
||||||
|
|
||||||
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
|
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
|
||||||
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
|
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
|
||||||
@@ -348,6 +367,19 @@ It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
|
|||||||
no workflow edit; enumerating packages by hand in the workflow is what
|
no workflow edit; enumerating packages by hand in the workflow is what
|
||||||
previously left `plugins/tray` unanalyzed and untested.
|
previously left `plugins/tray` unanalyzed and untested.
|
||||||
|
|
||||||
|
### CI Caches
|
||||||
|
|
||||||
|
A run restores only cache entries saved on its own branch or tag, or on the default branch (`main`), and the
|
||||||
|
repository holds 10 GB before evicting the least recently used entries. Every release is a new tag that no later run
|
||||||
|
reads from, so `BRANCH_CACHE` turns caching off in tag runs: one release used to write about 9 GB of such entries and
|
||||||
|
evict everything `dev` had saved. Release builds therefore start cold; branch runs, chiefly `dev`, keep the caches.
|
||||||
|
|
||||||
|
- setup-go keys its entry on the `cache-dependency-path` files alone and saves only on a miss, so jobs listing the
|
||||||
|
same files share one entry holding the build cache of whichever finished first. `desktop` and `android` each add
|
||||||
|
the file that drives their Core build to get an entry of their own.
|
||||||
|
- setup-gradle writes only on the default branch unless told otherwise, so `android` lets `dev` write as well. Its
|
||||||
|
entries are keyed by job, which is one more reason the release build gains nothing from caching.
|
||||||
|
|
||||||
## Worktree Tooling
|
## Worktree Tooling
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -91,3 +91,13 @@ listed in the SDK's `CHANGELOG.md`.
|
|||||||
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
|
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
|
||||||
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
|
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
|
||||||
must log `AppLifecycleState.hidden`.
|
must log `AppLifecycleState.hidden`.
|
||||||
|
|
||||||
|
## Liquid glass saves around the transform of a blurring layer
|
||||||
|
|
||||||
|
- **Bug:** not filed upstream. On 3.47 Impeller scales a `saveLayer`'s image filter by a transform set inside the
|
||||||
|
layer that is still in effect when the layer is restored. A host's capture shrinks each pane's slot inside the
|
||||||
|
layer that blurs it, so its blur vanished at a quarter scale and spread half again as far at 1.5.
|
||||||
|
- **Workaround:** `_LiquidGlassHostLayer._snapshot` sets each slot's transform inside a `save` of its own, and each
|
||||||
|
band's offset, restored before the layer is.
|
||||||
|
- **Remove when:** without that `save`, the frosted-pane case of `test/widgets/liquid_glass_host_test.dart` passes
|
||||||
|
under `flutter test --enable-impeller`.
|
||||||
|
|||||||
+59
-8
@@ -56,12 +56,31 @@ with an enclosing clip or shape where it shows. Framework widgets that paint the
|
|||||||
decoration to override, such as the `Switch` track, are left as Material draws them. Tooltips take theirs from
|
decoration to override, such as the `Switch` track, are left as Material draws them. Tooltips take theirs from
|
||||||
`withAppShapes`, which sets `tooltipTheme.decoration`.
|
`withAppShapes`, which sets `tooltipTheme.decoration`.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
Text sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in
|
||||||
|
`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two
|
||||||
|
steps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with
|
||||||
|
`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,
|
||||||
|
semibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text
|
||||||
|
sized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's
|
||||||
|
roles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`
|
||||||
|
maps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.
|
||||||
|
|
||||||
|
App bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in
|
||||||
|
`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,
|
||||||
|
such as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset
|
||||||
|
clear of the button on either side.
|
||||||
|
|
||||||
|
`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or
|
||||||
|
`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.
|
||||||
|
|
||||||
### Length Units
|
### Length Units
|
||||||
|
|
||||||
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
|
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
|
||||||
as in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write
|
as in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write
|
||||||
"physical pixel" in full where the code really works in them, as the hairline snapping in
|
"physical pixel" in full where the code really works in them, as the hairline snapping in
|
||||||
`lib/widgets/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
|
`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
|
||||||
`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`
|
`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`
|
||||||
and `test/`.
|
and `test/`.
|
||||||
|
|
||||||
@@ -102,7 +121,7 @@ judgment, and it lives in the rules below and in review. Whether comments are to
|
|||||||
that contains them. This does not need approval.
|
that contains them. This does not need approval.
|
||||||
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
|
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
|
||||||
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
|
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
|
||||||
code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
|
code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.
|
||||||
|
|
||||||
### Density
|
### Density
|
||||||
|
|
||||||
@@ -154,7 +173,8 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
|
|||||||
A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows
|
A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows
|
||||||
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
|
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
|
||||||
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the
|
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the
|
||||||
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
|
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose
|
||||||
|
`file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.
|
||||||
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
|
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
|
||||||
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
|
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
|
||||||
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
|
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
|
||||||
@@ -371,7 +391,31 @@ land.
|
|||||||
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
|
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
|
||||||
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
|
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
|
||||||
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
|
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
|
||||||
the singleton calls `CoreController.resetInstance()` in `tearDown`.
|
the singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`
|
||||||
|
when nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which
|
||||||
|
in a shared isolate is another file's.
|
||||||
|
|
||||||
|
### Shared Test Isolate
|
||||||
|
|
||||||
|
`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and
|
||||||
|
every file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and
|
||||||
|
`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test
|
||||||
|
sees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:
|
||||||
|
|
||||||
|
- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`
|
||||||
|
from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked
|
||||||
|
`appPath` yet.
|
||||||
|
- `preferences` opens its store once per process. Use `await mockPreferences(values)` from
|
||||||
|
`test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`
|
||||||
|
on the store it already opened.
|
||||||
|
|
||||||
|
Both cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs
|
||||||
|
its listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`
|
||||||
|
bodies for the same reason.
|
||||||
|
|
||||||
|
A test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught
|
||||||
|
its write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such
|
||||||
|
a file alone so it cannot fail the run, and lists it; fix it where it reads the global.
|
||||||
|
|
||||||
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
|
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
|
||||||
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
|
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
|
||||||
@@ -455,9 +499,9 @@ A public top-level declaration that nothing outside its own file references is d
|
|||||||
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
|
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
|
||||||
typedefs are skipped: those are reached through the types they attach to, never by name.
|
typedefs are skipped: those are reached through the types they attach to, never by name.
|
||||||
|
|
||||||
`CommonScaffold` adds the height of its docked search and floating action button to `BottomInsetScope` around its
|
`CommonScaffold` adds the height of its docked search to `BottomInsetScope` around its body, so a page that reads
|
||||||
body, so a page that reads `BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset
|
`BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset without it, and the end of
|
||||||
without them, and the end of its content stays under the search field or the button. Read it below the scaffold,
|
its content stays under the search field. Read it below the scaffold,
|
||||||
inside `body: Builder(builder: (context) => ...)`. `test/lint/scaffold_bottom_inset_test.dart` enforces this by
|
inside `body: Builder(builder: (context) => ...)`. `test/lint/scaffold_bottom_inset_test.dart` enforces this by
|
||||||
scanning `lib/`.
|
scanning `lib/`.
|
||||||
|
|
||||||
@@ -564,6 +608,12 @@ data move, or a test for one, check that the state it reads shipped: `git tag --
|
|||||||
introduced it, or `git show <tag>:<path>` for the file as the release had it. A state that only ever existed between
|
introduced it, or `git show <tag>:<path>` for the file as the release had it. A state that only ever existed between
|
||||||
two releases gets no migration; a development install holding it may lose that setting or fall back to its default.
|
two releases gets no migration; a development install holding it may lose that setting or fall back to its default.
|
||||||
|
|
||||||
|
A release that resets or rewrites settings, instead of reading their old shape, adds a step to `_upgrades` in
|
||||||
|
`lib/common/migration.dart` under the next `upgradeVersion`. A step takes and returns the `Config`, runs once on an
|
||||||
|
install that already had settings, and never on a new one. Leave `currentVersion` for changes to the stored format:
|
||||||
|
backups carry it, and an older app refuses a backup stamped newer than its own, so bumping it for a reset would strand
|
||||||
|
backups between devices on different releases.
|
||||||
|
|
||||||
The database takes one migration step per release. The newest step in `lib/database/database.dart` has not shipped
|
The database takes one migration step per release. The newest step in `lib/database/database.dart` has not shipped
|
||||||
while `schemaVersion` at the last release tag is below its number; a schema change then goes into that step, raising
|
while `schemaVersion` at the last release tag is below its number; a schema change then goes into that step, raising
|
||||||
`schemaVersion` and the step's condition together so a development install runs the step again, instead of adding a
|
`schemaVersion` and the step's condition together so a development install runs the step again, instead of adding a
|
||||||
@@ -578,6 +628,7 @@ Do not manually edit generated files under:
|
|||||||
- `lib/models/generated/`
|
- `lib/models/generated/`
|
||||||
- `lib/providers/generated/`
|
- `lib/providers/generated/`
|
||||||
- `lib/database/generated/`
|
- `lib/database/generated/`
|
||||||
|
- `lib/ui/generated/`
|
||||||
- `lib/l10n/intl/`
|
- `lib/l10n/intl/`
|
||||||
|
|
||||||
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
|
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
|
||||||
@@ -591,7 +642,7 @@ l10n paths for that reason. It is harmless because the file only needs `Locale`,
|
|||||||
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
|
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
|
||||||
survives only as a transitive dependency of `material_ui`.
|
survives only as a transitive dependency of `material_ui`.
|
||||||
|
|
||||||
Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
|
Strings live in `arb/intl_{en,zh_CN,ja,ru,fr,es,ko}.arb` — flat JSON, no `@` metadata. Add a key to all seven, then regenerate with
|
||||||
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
|
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
|
||||||
English at runtime, so add the translation rather than leaving it out.
|
English at runtime, so add the translation rather than leaving it out.
|
||||||
|
|
||||||
|
|||||||
+18
-12
@@ -23,9 +23,9 @@ The update path carries these fields:
|
|||||||
|
|
||||||
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
|
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
|
||||||
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
|
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
|
||||||
`geox-url`, and every `tun` field.
|
`geox-url`, and the `tun` keys of `Tun.coreJson`: `enable`, `device`, `stack`, and `auto-route`, always on.
|
||||||
- From other settings: `NetworkProps.authentication` and `bypassPrivateRoute`, which `Tun.getRealTun` folds into the
|
- From other settings: `NetworkProps.authentication`, and `AppSettingProps.checkCertificate`, sent as
|
||||||
TUN exclusions, and `AppSettingProps.checkCertificate`, sent as `skip-cert-verify`.
|
`skip-cert-verify`.
|
||||||
|
|
||||||
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
|
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
|
||||||
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
|
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
|
||||||
@@ -45,15 +45,17 @@ Prefer update whenever Go has a setter for the setting, because an apply reloads
|
|||||||
## Apply
|
## Apply
|
||||||
|
|
||||||
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
|
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
|
||||||
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds two values:
|
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds three values:
|
||||||
|
|
||||||
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
|
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
|
||||||
- `NetworkProps.appendSystemDns`.
|
- `NetworkProps.appendSystemDns`.
|
||||||
|
- `NetworkProps.bypassPrivateRoute` on desktop, where the task adds the private ranges to the TUN's final
|
||||||
|
`route-exclude-address`.
|
||||||
|
|
||||||
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS override (`dns`,
|
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS and TUN overrides (`dns`,
|
||||||
`dnsOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`, `redir-port`, `tproxy-port`), the dialer
|
`dnsOverrideKeys`, `tunOverride`, `tunOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,
|
||||||
options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode, `routing-mark`), `geodata-loader` and
|
`redir-port`, `tproxy-port`), the dialer options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,
|
||||||
`global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
|
`routing-mark`), `geodata-loader` and `global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
|
||||||
the profile build reads from another settings object has to be added to `setupPatchProvider`.
|
the profile build reads from another settings object has to be added to `setupPatchProvider`.
|
||||||
|
|
||||||
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
|
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
|
||||||
@@ -64,9 +66,10 @@ Profile content does not go through `setupPatchProvider`. `setupStateProvider` r
|
|||||||
database once instead of watching them. Content is applied explicitly by whatever changes it:
|
database once instead of watching them. Content is applied explicitly by whatever changes it:
|
||||||
|
|
||||||
- Profile editors call `autoApplyProfile` when they close.
|
- Profile editors call `autoApplyProfile` when they close.
|
||||||
- Subscription updates, `setProfileAndAutoApply`, scripts and app-level providers call `applyProfileDebounce`.
|
- Subscription updates, scripts and app-level providers call `applyProfileDebounce`.
|
||||||
- A profile switch runs `fullSetup`.
|
- A profile switch runs `fullSetup`.
|
||||||
- A start applies again with `force`.
|
- A start applies again with `force` unless the last apply saw the same inputs (`_inputsApplied`), the `testUrl` and
|
||||||
|
`skipCertVerify` that only `SetupParams` carries included.
|
||||||
|
|
||||||
## Restart
|
## Restart
|
||||||
|
|
||||||
@@ -116,8 +119,11 @@ options, because the service does not report the options it was started with.
|
|||||||
Each path watches its own provider, so a single setting can take more than one of them:
|
Each path watches its own provider, so a single setting can take more than one of them:
|
||||||
|
|
||||||
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
|
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
|
||||||
- TUN fields and `bypassPrivateRoute`: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the
|
- TUN switch and stack: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the restart notice
|
||||||
restart notice carries the change.
|
carries the stack.
|
||||||
|
- MTU, congestion control and routes: Android's rows take the restart path; on desktop they are TUN overrides, applied
|
||||||
|
with the profile.
|
||||||
|
- `bypassPrivateRoute`: apply on desktop, restart on Android.
|
||||||
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
|
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
|
||||||
restart.
|
restart.
|
||||||
|
|
||||||
|
|||||||
@@ -19,27 +19,41 @@ dialogs, and interaction behavior.
|
|||||||
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
|
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
|
||||||
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
|
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
|
||||||
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
|
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
|
||||||
|
- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in
|
||||||
|
`lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.
|
||||||
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
|
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
|
||||||
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
|
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
|
||||||
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
|
- Every icon lives in `lib/ui/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
|
||||||
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
|
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
|
||||||
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
|
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
|
||||||
fill animation, and when to fill.
|
fill animation, and when to fill.
|
||||||
|
- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a
|
||||||
|
Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the
|
||||||
|
rule.
|
||||||
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
|
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
|
||||||
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
|
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
|
||||||
keep scrims from stacking.
|
keep scrims from stacking.
|
||||||
- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
|
- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
|
||||||
`SheetProps.detents`, and a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
|
`SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
|
||||||
the window width (`lib/widgets/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`). Do not wrap a
|
the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet
|
||||||
sheet's content in a fixed width or height to resize one sheet.
|
is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a
|
||||||
|
fixed width or height to resize one sheet.
|
||||||
|
- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled
|
||||||
|
fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.
|
||||||
|
- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`
|
||||||
|
caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text
|
||||||
|
(`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it
|
||||||
|
shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in
|
||||||
|
`lib/ui/snap_sheet.dart`).
|
||||||
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
|
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
|
||||||
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
|
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
|
||||||
`.agents/architecture.md` has the reasons.
|
`.agents/architecture.md` has the reasons.
|
||||||
- A page full of images loads them once its route has settled and builds no rows past the viewport until then:
|
- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work
|
||||||
`RouteSettledMixin` and `arrivalScrollCacheExtent`, described under Page Arrival in `.agents/architecture.md`.
|
that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport
|
||||||
- A page that waits on its data as it arrives shows its illustration alone (`NullStatus` without a label, in a
|
(`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.
|
||||||
`NullStatusSwitcher`) until all of it is ready, never a skeleton screen; Page Arrival in `.agents/architecture.md`
|
- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load
|
||||||
has the rule.
|
that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in
|
||||||
|
`.agents/architecture.md` has the rule.
|
||||||
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
||||||
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
||||||
- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
|
- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
|
||||||
@@ -57,7 +71,7 @@ dialogs, and interaction behavior.
|
|||||||
|
|
||||||
## Corner Radii
|
## Corner Radii
|
||||||
|
|
||||||
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
|
All corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its
|
||||||
builders. Never write a bare radius literal at a call site.
|
builders. Never write a bare radius literal at a call site.
|
||||||
|
|
||||||
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall
|
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall
|
||||||
@@ -68,10 +82,10 @@ reads as a pill at 24 and as a square at 400, so one radius everywhere is the wr
|
|||||||
| `none` | 0 | - | square edges, and the flat side of a grouped run |
|
| `none` | 0 | - | square edges, and the flat side of a grouped run |
|
||||||
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
|
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
|
||||||
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
|
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
|
||||||
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
|
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |
|
||||||
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
|
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
|
||||||
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
|
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
|
||||||
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
|
| `xxl` | 28 | over 200 | sheets, full-screen containers |
|
||||||
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
|
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
|
||||||
|
|
||||||
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
|
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
|
||||||
@@ -80,19 +94,21 @@ reads as a pill at 24 and as a square at 400, so one radius everywhere is the wr
|
|||||||
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
|
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
|
||||||
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
|
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
|
||||||
the size comes from a `LayoutBuilder` or scales with text.
|
the size comes from a `LayoutBuilder` or scales with text.
|
||||||
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
|
- `ThemeData.withAppShapes` in `lib/ui/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
|
||||||
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
|
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
|
||||||
button themes. Do not restate those shapes at call sites, and
|
button themes. Do not restate those shapes at call sites, and
|
||||||
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
|
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
|
||||||
|
- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and
|
||||||
|
`primaryContainer` only on a selected item.
|
||||||
|
|
||||||
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
|
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
|
||||||
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
|
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
|
||||||
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
|
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
|
||||||
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
|
group card in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
|
||||||
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
|
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
|
||||||
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
|
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at
|
||||||
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
|
`ProxyCardType.compact` is only about 64 tall, which caps its radius at 21, so `xl` would break on a setting the user
|
||||||
clears all three heights and the smallest dashboard tile.
|
can change at any time while `lg` clears both heights and the smallest dashboard tile.
|
||||||
|
|
||||||
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
|
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
|
||||||
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
|
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
|
||||||
@@ -100,12 +116,15 @@ up. `AppInputBorder` clips the notch region and paints the full superellipse thr
|
|||||||
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
|
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
|
||||||
|
|
||||||
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
|
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
|
||||||
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
|
the outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without
|
||||||
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
|
bound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies
|
||||||
|
list group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller
|
||||||
|
than every other card in the app.
|
||||||
|
|
||||||
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
|
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
|
||||||
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
|
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the
|
||||||
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
|
chip-sized `sm` and the near-pill `md` on a 40 tall row, and `_dialogCorner` in `lib/ui/shape.dart` does with 34, the
|
||||||
|
corner of an iOS alert. Reach for this only once the table and `fit()` are ruled out.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Surfaces
|
||||||
|
|
||||||
|
FlClash draws structure with fills and keeps color for state. A surface stands out from what it sits on by a step in
|
||||||
|
lightness, never by a border. Color appears where something is selected, on, failing, or the one action a screen is
|
||||||
|
for. The dashboard set this direction; every other screen follows it, and Material components keep their behavior
|
||||||
|
but give up their tonal container fills.
|
||||||
|
|
||||||
|
## Levels
|
||||||
|
|
||||||
|
`SurfaceLevel` in `lib/ui/surface.dart` names four depths. Each surface takes the level one deeper than the one
|
||||||
|
it sits on, which `SurfaceScope` passes down the tree.
|
||||||
|
|
||||||
|
| Level | Light | Dark | Holds |
|
||||||
|
|---------|---------------------------|---------------------------|---------------------------------------------------------|
|
||||||
|
| `page` | `surfaceContainer` | `surface` | scaffold body, sheets |
|
||||||
|
| `card` | `surfaceBright` | `surfaceContainer` | cards, grouped runs of rows, light-mode floats |
|
||||||
|
| `block` | `surfaceContainer` | `surfaceContainerHigh` | unselected segments, tiles and wells in a card |
|
||||||
|
| `inset` | `surfaceContainerHighest` | `surfaceContainerHighest` | small marks inside a block: glyph wells, badges, tracks |
|
||||||
|
|
||||||
|
Light mode alternates gray and paper: a near-white card on a gray page, a gray block in the card. The card is tone 98,
|
||||||
|
not pure white, and the page tone 94: pure white beside a tinted gray made the white glare and the gray look soiled. Dark mode only
|
||||||
|
lightens, because a darker step inside a lighter surface reads as a hole rather than a layer. `inset` is the floor;
|
||||||
|
nothing nests below it, so a design that needs a fifth level needs fewer containers instead.
|
||||||
|
|
||||||
|
- A widget never picks its level at the call site. `CommonCard` and the other shared surfaces read
|
||||||
|
`context.raisedSurfaceLevel` and wrap their child in `SurfaceScope`, so the same row is white on a page and gray
|
||||||
|
inside a card. A branch on `isInBottomSheet` or on where a widget is placed to choose its fill is the bug this
|
||||||
|
replaces.
|
||||||
|
- Sheets are `page` level, like a page, so a section looks the same in a sheet and in a page. In dark mode a sheet
|
||||||
|
presents a level up, as iOS presents a sheet on its elevated backgrounds: `ColorScheme.elevated` lifts every
|
||||||
|
surface role by the step from `page` to `card`, so the sheet's page takes the tone of the cards it covers and every
|
||||||
|
level inside it follows. Under pure black the sheet takes the plain dark scheme instead, which is the same step up
|
||||||
|
and keeps the seed's tint that a lift from black would lose. `ElevatedColorScheme` carries the result and the sheet
|
||||||
|
routes apply it through `ElevatedSurfaceTheme`; a sheet opened from a sheet keeps the same lift. A scrim barely
|
||||||
|
darkens a dark page, so a dark sheet at the page's own tone read as more of the page rather than a layer over it,
|
||||||
|
a covered sheet's peek vanished, and on black the edge and corners went with it. Light mode keeps its levels,
|
||||||
|
because the scrim already sets a light sheet apart.
|
||||||
|
- What floats over content, dialogs, menus, popups, the mobile dock, toasts, takes `SurfaceLevel.floatingOf`: `card`
|
||||||
|
in light, `block` in dark. A shadow does not show on a dark page, so a float has to come out lighter than the cards
|
||||||
|
it covers, and its content scopes to that level so a block inside still steps away from it.
|
||||||
|
- Shadows mark what floats, plus the dashboard cards. Cards in a scrolling list have none; a grid of proxies with a
|
||||||
|
blurred shadow each costs raster time for no extra layering. A render object that paints a shadow itself draws it
|
||||||
|
with `drawRSuperellipse`, as `ShapeDecoration` does: Impeller blurs a superellipse in one analytic pass, but a path
|
||||||
|
through an offscreen Gaussian blur on every frame, which made the shadows the largest raster cost of an opening menu.
|
||||||
|
|
||||||
|
## The Seeded Scheme
|
||||||
|
|
||||||
|
`seededColorScheme` in `lib/ui/color.dart` builds every app scheme: Material's scheme for the seed and the
|
||||||
|
`ColorStyle` picked in the theme settings, with three groups of roles replaced. Everything else stays Material,
|
||||||
|
including `toPureBlack` applied after it.
|
||||||
|
|
||||||
|
The four styles each change something a reader sees at once. Material's nine variants mostly moved secondary and
|
||||||
|
tertiary, which the app barely shows, so most of them looked alike once surfaces stayed gray.
|
||||||
|
|
||||||
|
| Style | Material variant | Primary | Surfaces |
|
||||||
|
|--------------|------------------|-------------------|---------------------------------|
|
||||||
|
| `vivid` | content | the seed | near gray |
|
||||||
|
| `tinted` | content | the seed | the seed's hue, up to chroma 10 |
|
||||||
|
| `soft` | tonal spot | Material's, muted | near gray |
|
||||||
|
| `monochrome` | monochrome | black or white | gray |
|
||||||
|
|
||||||
|
|
||||||
|
- **Surfaces stay near gray.** Each `surface*` role keeps Material's tone but its chroma is capped at 3, the light
|
||||||
|
`surface` and `surfaceBright` at 1.5, and `outlineVariant` at 4. Material tints neutrals by the seed (content
|
||||||
|
divides the seed's chroma by 8), so a yellow, green, or rose seed tinted the whole page. `tinted` is that wash
|
||||||
|
chosen on purpose and made even: every surface takes the seed's hue at a sixth of its chroma, capped at 10, so a
|
||||||
|
gray seed stays gray and any vivid one tints about as much.
|
||||||
|
- **`primaryContainer` comes from the primary palette.** It is primary's hue at tone 90 in light and 30 in dark,
|
||||||
|
with `onPrimaryContainer` at 30 and 90, and the pair's chroma is capped at 48. Content and fidelity otherwise put it
|
||||||
|
at the seed's own tone: a yellow seed filled it with pure yellow, a red seed with something that read as an error,
|
||||||
|
and dark mode with a light block under light text. The cap matters for light, chromatic hues: yellow at tone 90 is
|
||||||
|
still close to full strength.
|
||||||
|
- **`vivid` and `tinted` keep the seed as `primary`**, as an Apple accent color does: the color picked in the theme
|
||||||
|
settings is the color of every check, switch, and filled button. Light holds the seed's tone between 35 and 60, so
|
||||||
|
white text on it keeps about 3:1 and a near-black seed still reads as a color; dark lifts it 3 tones, as iOS lifts
|
||||||
|
its system colors, within 50 to 75, and puts dark text on it above 60. Material sets primary at tone 40 and 80 for
|
||||||
|
any seed, which turned iOS blue into navy in light and a pastel in dark. `soft` and `monochrome` keep Material's
|
||||||
|
primary, since moving away from the seed is what they are for.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
Color marks the one item that differs, never the structure around it.
|
||||||
|
|
||||||
|
- **Selected**, one of several rows, cards, or tiles: the item keeps its level's fill and a primary mark says which,
|
||||||
|
as iOS checks the option picked from a list. A row in a grouped run takes `SelectionCheck` at its edge; a card that
|
||||||
|
stands on its own, a profile or a proxy, and a tile take the filled `SelectionBadge`, which holds its own beside a
|
||||||
|
40 dp icon well where the bare check read as small and thin (`lib/ui/selection_mark.dart`). `CommonCard.isSelected` only reports the state to
|
||||||
|
accessibility. Fills were tried and rejected: a wash of primary landed on a gray that did not read as a choice, and
|
||||||
|
`primaryContainer` read as a warning under rose seeds in light and as a heavy slab in dark, muddied the translucent
|
||||||
|
marks on it, and cut a grouped run in two.
|
||||||
|
- **Multi-select** in edit mode: the circle checkbox is the mark; the row stays at its level.
|
||||||
|
- **Proxy groups** have two states in URLTest and Fallback: the check badge marks the proxy traffic goes through, in
|
||||||
|
every group type, and a pin badge (`SelectionBadge.preferred`) marks the preferred proxy (mihomo's `fixed`): filled
|
||||||
|
like the check while the group uses it, tonal while the group has fallen back to another proxy, which carries the
|
||||||
|
check.
|
||||||
|
- **Chosen segment** in a connected button group, a mode switch, or a run of compact choice chips
|
||||||
|
(`CommonCard.fillsSelection`): solid `primary` with `onPrimary`, as the dashboard outbound mode does. Only one
|
||||||
|
segment per group.
|
||||||
|
- **Error** on a row or card: `errorContainer` with `onErrorContainer`, no border.
|
||||||
|
- **Status** values (delay, availability, success, warning): the color goes on text, a glyph, or a small chip, never
|
||||||
|
on a whole card. `success` and `warning` come from fixed green and orange palettes that ignore the seed, like
|
||||||
|
Material's `error`; harmonizing pulled orange toward red under rose seeds until warning and error matched. Text takes
|
||||||
|
tone 40 in light and 80 in dark, a chip takes `successContainer`/`warningContainer` (90/30) with the `on` role
|
||||||
|
(30/90). A delay under 600 ms is success, above it warning, a failure error: `ColorScheme.delayColor`.
|
||||||
|
- **Destructive** action in an alert: `ColorScheme.destructive`, iOS 27's systemRed in both modes, not `error`. A
|
||||||
|
menu's `danger` item still takes `error`.
|
||||||
|
- **Data series** on one chart: the first is primary, the second primary at 45% over the card
|
||||||
|
(`DashboardColors.series2`), so they differ by lightness under any seed. Tertiary is not a series color.
|
||||||
|
- **Text**: titles and values `onSurface`, labels and secondary lines `onSurfaceVariant`. `outline` is not a text
|
||||||
|
color, and a value is never tinted primary.
|
||||||
|
- **Marks** that only identify, a country badge, a group or service icon, a source avatar, sit on a neutral well with
|
||||||
|
`onSurfaceVariant`.
|
||||||
|
- `primaryContainer`, `secondaryContainer`, and `tertiaryContainer` are neither surface nor selection fills. Keep
|
||||||
|
them out of cards, rows, marks, and navigation indicators.
|
||||||
|
|
||||||
|
## Borders That Stay
|
||||||
|
|
||||||
|
- **Focus**: a 2 dp primary stroke while a surface has keyboard or remote focus. TV navigation depends on it, and a
|
||||||
|
fill change alone is too faint to follow from a couch. A card, a grouped row above all, rings only focus a key moved
|
||||||
|
there (`FocusMovedByKey`), or any focus while a remote drives the app: a tap or a click puts focus where the reader
|
||||||
|
is already looking, a field's caret already marks it, and the ring then read as a selection box around the row.
|
||||||
|
- **Inputs**: a form's field is a bare `FormRowTextField` at the end of a grouped `FormRow` whose title labels it, as
|
||||||
|
iOS and macOS set one, with no stroke or fill of its own. A field among filled controls takes their fill, one level
|
||||||
|
above its surface, through `FilledInputTheme`. An alert's fields, because they sit over its pill buttons, as
|
||||||
|
UIAlertController sets them: as wide as the buttons, in their fill, with their text in line with the title and the
|
||||||
|
label as the placeholder (`CommonDialog.hasFields`); a field under a filled block of text in an alert takes the
|
||||||
|
block's width and corner instead. The custom color's hex field, because its card holds only fills, the color
|
||||||
|
previews, where an outline was the one stroked thing. Search fields are filled pills. A sheet's docked search,
|
||||||
|
because the list scrolls under it: it floats like the dock, with the floating fill and the dock's shadow, or the
|
||||||
|
dock's glass. The editor's find bar, because its fields sit in a row of filled pill buttons: they take the buttons'
|
||||||
|
fill, one level above the bar. All of these stroke only while focused. Any other field, one standing alone on a page,
|
||||||
|
is outlined, with the floating label notched into the stroke: `ThemeData.withAppShapes` gives every input
|
||||||
|
`AppShape.input`, so call sites leave `border` unset. A lone field is never filled: here a fill means structure, so
|
||||||
|
one read as one more card or row, and in light mode the hover overlay brought it down to the page's own tone. No
|
||||||
|
field counts its characters, as none does on iOS or macOS; a length limit holds through its formatter. Material
|
||||||
|
strokes a filled field's whole outline in `onSurfaceVariant`, which ringed the pill brighter than anything else in a
|
||||||
|
dark sheet.
|
||||||
|
- **Window chrome**: the desktop sidebar's inner edge and the title bar's bottom edge draw a divider where they meet
|
||||||
|
the content. The chrome fill alone left the edge to a four-tone step, which read as a smudge rather than a boundary.
|
||||||
|
|
||||||
|
Nothing else draws an outline. Rows in a grouped run are separated by the hairline from `lib/ui/list_row_separator.dart`, which divides
|
||||||
|
one surface and does not outline it. Record lists, requests, connections, logs, and DNS queries, are not grouped: they
|
||||||
|
run to thousands of tall rows, and one card that long reads as a slab rather than a group. A stacked row of three or
|
||||||
|
four lines is already its own block, so each is a card of its own, `RecordListItem`: the raised fill at `xl`, 16 dp
|
||||||
|
from the edges and 8 dp from the next, as the profile cards sit. Every card takes the same fill, so the warning and
|
||||||
|
error tints on a label still stand out, where a fill on every other row competed with them; rows on the page split by
|
||||||
|
a full-width divider were the one structural line left on screens otherwise drawn with fills. Laid out as a table,
|
||||||
|
where a row is one line read across many columns, they drop the gap and give every other row the raised fill, inset
|
||||||
|
and rounded like the column header, so the eye keeps its row; the dashboard's table does the same.
|
||||||
|
|
||||||
|
Every divider takes its color from `ThemeData.withAppSurfaces`: `onSurface` at 14%, never `outlineVariant`. A
|
||||||
|
translucent line keeps the same contrast on the page, a card, or the chrome, in both modes; `outlineVariant` stood out
|
||||||
|
twice as much on the dark page as on the light one and carried the seed's tint.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
- The desktop sidebar and title bar are window chrome, `ColorScheme.chromeSurface`: `surfaceContainerHighest` in
|
||||||
|
light and `surfaceContainerLow` in dark, four tones from the page either way, with a divider along the edge that
|
||||||
|
meets the content. At `page` level the sidebar merged with the content. In dark the chrome stays below `card`, so
|
||||||
|
cards still stand out from it. The pane the sidebar opens over the content takes the same fill and edge, plus a
|
||||||
|
shadow.
|
||||||
|
The selected destination keeps its neutral fill and primary bar, which already marks it with color.
|
||||||
|
- The mobile dock and the docked navigation bar are floats; their indicator is a neutral platter one level above
|
||||||
|
them, and the selected destination's content takes primary, as iOS's tab bar marks its selection.
|
||||||
|
- With Liquid Glass on (Theme settings), the dock, the buttons of a page's bar, a sheet's docked search, popup
|
||||||
|
menus, and alerts are glass from `lib/ui/liquid_glass.dart` instead of a fill, and the dock's platter takes the
|
||||||
|
secondary hue and chroma at tone 90, or at tone 4 in dark, which sinks it below the bar as iOS 27 does.
|
||||||
|
- The proxies page's group tabs are compact choice chips, the current group in the chosen segment's solid `primary`.
|
||||||
|
Once they overflow, a tonal button at their end opens every group as a popup menu, the current one checked and
|
||||||
|
scrolled into view. A profile can carry dozens of groups, so the menu stops at seven rows and half of the next.
|
||||||
|
|
||||||
|
## Where It Lives
|
||||||
|
|
||||||
|
- `lib/ui/surface.dart`: `SurfaceLevel`, `SurfaceScope`, `context.surfaceLevel`, `context.raisedSurfaceLevel`,
|
||||||
|
`ColorScheme.chromeSurface`, and `ThemeData.withAppSurfaces`, applied next to `withAppShapes` wherever the app
|
||||||
|
builds its theme.
|
||||||
|
- `lib/ui/color.dart`: `seededColorScheme`, the status palettes, and `delayColor`.
|
||||||
|
- `lib/views/dashboard/style.dart`: `DashboardColors` maps `page`, `card`, `tone`, and `tone2` onto the levels and
|
||||||
|
adds the dashboard's switch, series, and status colors.
|
||||||
|
|
||||||
|
## Checking A Change
|
||||||
|
|
||||||
|
Render the touched screens in light and dark, on a phone width and a desktop width, before and after. A throwaway
|
||||||
|
widget test does it: pump `HomePage` or the view inside a `RepaintBoundary`, load Roboto with `FontLoader` so text
|
||||||
|
gets real glyphs, and write `RenderRepaintBoundary.toImage` to a PNG outside the repository. Look for a surface that
|
||||||
|
matches the one it sits on, which means it skipped `SurfaceScope`, and for a tonal container fill that survived on a
|
||||||
|
card or row.
|
||||||
@@ -17,6 +17,8 @@ Read these only when the task touches their area:
|
|||||||
local plugins.
|
local plugins.
|
||||||
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
|
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
|
||||||
profile, or asks for an Android VPN restart.
|
profile, or asks for an Android VPN restart.
|
||||||
|
- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that
|
||||||
|
remain, for any change to how a surface is colored.
|
||||||
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
|
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
|
||||||
command rules, and hooks.
|
command rules, and hooks.
|
||||||
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
|
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
|
||||||
@@ -55,7 +57,7 @@ Read these only when the task touches their area:
|
|||||||
requests.
|
requests.
|
||||||
- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
|
- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
|
||||||
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
|
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
|
||||||
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/common/shape.dart`,
|
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/shape.dart`,
|
||||||
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
|
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
|
||||||
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
|
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
|
||||||
[.agents/rules.md](.agents/rules.md).
|
[.agents/rules.md](.agents/rules.md).
|
||||||
|
|||||||
Reference in New Issue
Block a user