11 KiB
name, description
| name | description |
|---|---|
| ui-work | Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, or user-facing interactions. |
UI Work
When To Use
Use this for user-facing Flutter UI changes in lib/, including widgets, screens, navigation surfaces, settings rows,
dialogs, and interaction behavior.
Conventions
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
look the rest of the app has: Material You from
material_uias the base, with Apple's interaction conventions (continuous corners, sheets that snap between detents, a floating tab bar) built on Material widgets rather than Cupertino ones. Do not introduce a visual system for one screen or rewrite a layout the request does not need. .agents/rules.mdis binding here: the lint set (child:last,const, final locals), the superellipse corner API, and the widget-test facts under Testing Rules (pumpAndSettlenever returns onEditorView,dispose()must not 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 inlib/widgets/. The UI Layer section of.agents/architecture.mdsays how alib/uicomponent gets app state. - Settings screens are built from the
ConfigToggleItemfamily inlib/widgets/config_item.dart; the Settings Rows section of.agents/architecture.mdsays when a file-local helper or a named widget is warranted. - Every icon lives in
lib/ui/icons/, never the MaterialIconsfont: reuse or add a glyph inAppGlyphsand show it withGlyphIcon, or withAnimatedGlyphwhere it fills on selection. Glyphs are outlined, except inside a filled button, where they takefill: 1. The Icons section of.agents/architecture.mdcovers shape roles, the stroke, the fill animation, and when to fill. - Text sizes and weights come from
AppTextStyleinlib/ui/typography.dart, Apple's iOS Dynamic Type scale, never a MaterialTextThemeslot, a barefontSize, or afontWeight; the Typography section of.agents/rules.mdhas the rule. - Modals dim what they cover with the Material 3 scrim (
ColorScheme.modalScrim,scrimat 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 keep scrims from stacking. - A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
SheetProps.detents, a side sheet is as tall as the window and as wide asModalSideSheetRoutemakes it from the window width (lib/ui/side_sheet.dart, pinned bytest/widgets/side_sheet_width_test.dart), and a form sheet is a cardFormSheetRoutecaps 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
showFormSheetand lays its values out inFormRows, never as labeled fields in aCommonDialog; a prompt for one value stays an alert. Forms in.agents/architecture.mdhas the rule. - A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view:
CommonDialogcaps 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 (_topinlib/ui/snap_sheet.dart). - The keyboard animates
MediaQuery.viewInsetsevery frame. Read MediaQuery by aspect (paddingOf,viewInsetsOf) rather thanMediaQuery.of, and wrap the page of a new route type inKeyboardInsetHold; Keyboard Insets in.agents/architecture.mdhas the reasons. - A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work
that repeats, such as a live feed's refreshes (
RouteMotionHoldMixin), and the rows past the viewport (arrivalScrollCacheExtent). Page Arrival in.agents/architecture.mdhas the rule. - A page that waits on its data shows nothing until all of it is ready (
NullStatusSwitcher.isLoading), and a load that can run long adds its illustration asloadingStatus, never a skeleton screen; Page Arrival in.agents/architecture.mdhas the rule. - App bar buttons reach
CommonScaffoldas data (primaryAction,iconActions,menuItems) so it can fold them into its overflow menu; App Bar Actions in.agents/architecture.mdhas the rule. - A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
reorders in a sort mode, any other by a long press on the row. Row Actions And Sort Mode in
.agents/architecture.mdhas the rule. - Quick edit belongs only on a page that edits a whole list or section, or one isolated item; never on one that edits a
part of something other settings also write. Quick Edit in
.agents/architecture.mdhas the rule. - A page or sheet opens on the navigator of the context it comes from, never on the root navigator unless Where A
Route Opens in
.agents/architecture.mdallows it. - User-facing text goes through ARB; use
localizationwhen text changes are non-trivial. - An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
finallyfor animations and timers.CoreStatusButtonand the proxies delay test action are the reference implementations, described under User-Facing Core And Delay Feedback in.agents/architecture.md.
Corner Radii
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.
The scale is picked by the component's shortest side. A radius that reads as a soft card at 64 dp tall reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
| Token | Value | Shortest side | Use |
|---|---|---|---|
none |
0 | - | square edges, and the flat side of a grouped run |
xs |
4 | inset blocks | tiles clipped inside an already rounded surface |
sm |
8 | up to 48 | chips, thumbs, swatches, small square tiles |
md |
16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |
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 |
xxl |
28 | over 200 | sheets, full-screen containers |
full |
1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
AppCornerholds the scale asdouble;AppRadiusmirrors it asBorderRadiuswithall,top, andverticalbuilders;AppShapemirrors it asRoundedSuperellipseBorder,fullincluded, pluscircle,input, and theall/top/vertical/ofbuilders. UseAppShape.fullfor pills, neverStadiumBorder; the Corner Radius section of.agents/rules.mdexplains 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 the size comes from aLayoutBuilderor scales with text.ThemeData.withAppShapesinlib/ui/shape.dartalready 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 button themes. Do not restate those shapes at call sites, and leaveInputDecoration.borderunset so inputs inheritAppShape.input.- Colors of surfaces follow
.agents/surfaces.md: fills bySurfaceLevel, no structural borders, andprimaryContaineronly 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
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
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
same radius. lg is for mid-size cards that are not full-bleed: a proxy node card in the tab view at
ProxyCardType.compact is only about 64 tall, which caps its radius at 21, so xl would break on a setting the user
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
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
up. AppInputBorder clips the notch region and paints the full superellipse through it, as Flutter's own
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
the outer value, as _cardRadius in lib/ui/popup.dart does. Adding a token for one of these is how a scale grows without
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
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, 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
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
including the exception path of any async action. Run
flutter analyzeandflutter test test/widgets/plus the tests of the touched view. - The scans in
test/lint/fail on the common UI mistakes and run as part offlutter test: anIconButtonwhose icon is an icon needs atooltip; a field that constructs its own controller, notifier, or focus node is released in the same file; a public declaration nothing references is dead..agents/rules.mdexplains each rule and its allowlist.
Pitfalls
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors bypass them immediately.
- Do not leave loading animations active when callbacks throw.
- Do not manually edit generated localization or provider files.