The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
import "dart:math" as math;
|
||||
import "dart:ui" show Color, lerpDouble;
|
||||
import "package:flutter/painting.dart" show HSLColor;
|
||||
import "package:flutter/widgets.dart" show Brightness;
|
||||
|
||||
// The apps own colour scheme. Used to be a subclass of shadcns ColorScheme which
|
||||
// meant it kept getting flattened back to a plain base scheme every frame by the
|
||||
// theme lerp - now its just our own first class type, nothing above it.
|
||||
//
|
||||
// Holds the usual semantic slots (background/foreground/primary/...) plus the
|
||||
// app specific ones (chrome, panel borders, the control family) as equals.
|
||||
//
|
||||
// ── On the names ──────────────────────────────────────────────────────────
|
||||
// The control family used to be called input*. That was a lie measured
|
||||
// against its own call sites: of the eighteen reads of `inputBorder` exactly
|
||||
// one was a text field, and the rest were outline buttons, selects, date
|
||||
// inputs, checkboxes, radios, the colour swatch and toast. Anything with a
|
||||
// stroke around it took the "input" token because that was the only stroke
|
||||
// on offer. They're control* now, which is what they always were.
|
||||
//
|
||||
// `surfaceSunken` has the same history from the other end: it was
|
||||
// explorerRowEven, a zebra stripe, and every consumer bar one was using it as
|
||||
// "one step below the ground" for a whole pane. It's a rung on the ladder,
|
||||
// not a row colour, so it gets a rung's name.
|
||||
class ColourScheme {
|
||||
const ColourScheme({
|
||||
required this.brightness,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.card,
|
||||
required this.popover,
|
||||
required this.popoverBorder,
|
||||
required this.tooltipBackground,
|
||||
required this.tooltipBorder,
|
||||
required this.primary,
|
||||
required this.primaryHovered,
|
||||
required this.primaryForeground,
|
||||
required this.secondary,
|
||||
required this.secondaryHovered,
|
||||
required this.secondaryForeground,
|
||||
required this.muted,
|
||||
required this.mutedForeground,
|
||||
required this.destructive,
|
||||
required this.border,
|
||||
required this.divider,
|
||||
required this.surfaceSunken,
|
||||
required this.controlFill,
|
||||
required this.controlFillHovered,
|
||||
required this.controlFillFocused,
|
||||
required this.controlBorder,
|
||||
required this.switchTrackInactive,
|
||||
required this.ring,
|
||||
required this.chart1,
|
||||
required this.chart2,
|
||||
required this.chart3,
|
||||
required this.chart4,
|
||||
required this.chart5,
|
||||
required this.chrome,
|
||||
required this.panelBorder,
|
||||
required this.panelBorderHighlighted,
|
||||
required this.rowHovered,
|
||||
required this.rowText,
|
||||
required this.popoverItemHovered,
|
||||
required this.propertiesSectionBorder,
|
||||
});
|
||||
|
||||
/// Builds a complete scheme from the handful of colours an app actually
|
||||
/// chooses, deriving the rest.
|
||||
///
|
||||
/// ── How the derivation works ──────────────────────────────────────────
|
||||
///
|
||||
/// This used to be a table of HSL lightness offsets. HSL lightness is not
|
||||
/// perceptually uniform and, worse, an offset that runs off the end of the
|
||||
/// scale was *reflected*: it kept its size and lost only its direction. On
|
||||
/// a ground already near the floor that turned "recess this by twelve" into
|
||||
/// "raise it by twelve", and A&As carbon ended up with a canvas backdrop
|
||||
/// LIGHTER than the paper sat in front of it.
|
||||
///
|
||||
/// Measuring the ten hand authored schemes in CIE L* showed what they
|
||||
/// actually have in common, and its four rules rather than one table:
|
||||
///
|
||||
/// 1. Raised rungs are an absolute L* step off the ground. Zinc and carbon
|
||||
/// agree here to within 1-3 points despite sitting sixteen points apart,
|
||||
/// so this part was never the problem.
|
||||
///
|
||||
/// 2. Recessed rungs are the same idea until the ground runs out of room
|
||||
/// underneath, and then they all squeeze by the same factor. Zinc has
|
||||
/// 19.9 L* of basement and spends 12.6 of it on chrome. Carbon has 3.6
|
||||
/// and spends all of it. Squeezing keeps their ORDER, which is the thing
|
||||
/// that actually matters - reflecting destroyed it.
|
||||
///
|
||||
/// 3. A stroke is measured against the surface it outlines, not against the
|
||||
/// ground. Every scheme puts its popover border about ten points over
|
||||
/// its popover; none of them puts it ten points over the background.
|
||||
/// (This is the fix that landed for propertiesSectionBorder alone in
|
||||
/// 38a206f, generalised - it was never a one slot problem.)
|
||||
///
|
||||
/// 4. Text is a fraction of the background->foreground span, not an
|
||||
/// absolute shift. mutedForeground is 60.7% of the way in zinc and
|
||||
/// 60.7% in carbon, to one decimal, on spans of 71 and 92 points.
|
||||
///
|
||||
/// Anything you want to pin exactly is still an argument - every derived
|
||||
/// slot has an override. Hand authored schemes keep using the const
|
||||
/// constructor and are untouched.
|
||||
factory ColourScheme.derive({
|
||||
required Brightness brightness,
|
||||
required Color background,
|
||||
required Color foreground,
|
||||
required Color primary,
|
||||
Color? primaryForeground,
|
||||
Color? destructive,
|
||||
Color? ring,
|
||||
// --- escape hatches: pin any derived slot ---
|
||||
Color? chrome,
|
||||
Color? card,
|
||||
Color? popover,
|
||||
Color? muted,
|
||||
Color? tooltipBackground,
|
||||
Color? secondary,
|
||||
Color? border,
|
||||
Color? surfaceSunken,
|
||||
Color? controlFill,
|
||||
Color? controlFillHovered,
|
||||
Color? controlFillFocused,
|
||||
Color? controlBorder,
|
||||
Color? switchTrackInactive,
|
||||
Color? mutedForeground,
|
||||
Color? rowText,
|
||||
Color? chart1,
|
||||
Color? chart2,
|
||||
Color? chart3,
|
||||
Color? chart4,
|
||||
Color? chart5,
|
||||
}) {
|
||||
final groundL = _lstar(background);
|
||||
final span = _lstar(foreground) - groundL;
|
||||
|
||||
// How much of what the recessed rungs WANT this ground can actually give
|
||||
// them. chrome is the deepest slot the shared scheme has (the canvas
|
||||
// backdrop goes deeper, but thats an app side slot now), so it sets the
|
||||
// scale: if theres room for it nothing squeezes at all.
|
||||
final basement = brightness == Brightness.dark
|
||||
? groundL
|
||||
: math.max(groundL, _deepestSink);
|
||||
final squeeze = basement >= _deepestSink ? 1.0 : basement / _deepestSink;
|
||||
|
||||
Color raise(double points) => _atLstar(background, groundL + points);
|
||||
Color sink(double points) =>
|
||||
_atLstar(background, groundL - points * squeeze);
|
||||
|
||||
// a stroke sits N points off the surface it outlines. reflects if theres
|
||||
// no headroom that way - a border that cant get lighter than its own fill
|
||||
// gets darker by the same amount, which is what youd have picked anyway.
|
||||
Color edge(Color surface, double points) {
|
||||
final l = _lstar(surface);
|
||||
final wanted = l + points;
|
||||
return _atLstar(
|
||||
surface,
|
||||
(wanted > 100 || wanted < 0) ? l - points : wanted,
|
||||
);
|
||||
}
|
||||
|
||||
Color text(double fraction) =>
|
||||
_atLstar(foreground, groundL + span * fraction);
|
||||
|
||||
final resolvedChrome = chrome ?? sink(12.6);
|
||||
final resolvedCard = card ?? raise(5.9);
|
||||
final resolvedPopover = popover ?? sink(11.6);
|
||||
final resolvedMuted = muted ?? sink(9.1);
|
||||
final resolvedFill = controlFill ?? sink(9.1);
|
||||
final resolvedTooltip = tooltipBackground ?? sink(9.1);
|
||||
|
||||
return ColourScheme(
|
||||
brightness: brightness,
|
||||
background: background,
|
||||
foreground: foreground,
|
||||
|
||||
card: resolvedCard,
|
||||
|
||||
popover: resolvedPopover,
|
||||
popoverBorder: edge(resolvedPopover, 9.9),
|
||||
|
||||
tooltipBackground: resolvedTooltip,
|
||||
tooltipBorder: edge(resolvedTooltip, 9.1),
|
||||
|
||||
primary: primary,
|
||||
// +11 L*, CLAMPED rather than reflected. A stroke that cant get lighter
|
||||
// than its surface has to go the other way or it disappears, but a hover
|
||||
// has no such problem - it just wants to be brighter, and a near white
|
||||
// primary should hover to white, not turn round and dim to grey.
|
||||
primaryHovered: _atLstar(primary, math.min(100, _lstar(primary) + 11.0)),
|
||||
primaryForeground: primaryForeground ?? _contrastColor(primary),
|
||||
|
||||
secondary: secondary ?? raise(16.3),
|
||||
secondaryHovered: raise(23.9),
|
||||
// nine of the ten hand authored schemes put this exactly on foreground.
|
||||
secondaryForeground: foreground,
|
||||
|
||||
muted: resolvedMuted,
|
||||
mutedForeground: mutedForeground ?? text(0.607),
|
||||
|
||||
destructive:
|
||||
destructive ??
|
||||
(brightness == Brightness.dark
|
||||
? const Color(0xffa9575f)
|
||||
: const Color(0xffb3474f)),
|
||||
|
||||
// both reference schemes put the outermost line on the floor with
|
||||
// chrome - its the gutter between panels, not a stroke on a surface.
|
||||
border: border ?? resolvedChrome,
|
||||
// 4.1 points off the ground is a line you have to go looking for. The
|
||||
// shell has drawn its own rules with panelBorder (9.6) since forever
|
||||
// and nobody has ever called those loud, so a divider sits just under
|
||||
// that - visible, still subordinate to the edge of a panel.
|
||||
divider: edge(background, 9.0),
|
||||
|
||||
surfaceSunken: surfaceSunken ?? sink(3.8),
|
||||
|
||||
controlFill: resolvedFill,
|
||||
controlFillHovered: controlFillHovered ?? sink(6.5),
|
||||
controlFillFocused: controlFillFocused ?? sink(11.0),
|
||||
controlBorder: controlBorder ?? edge(resolvedFill, 11.8),
|
||||
|
||||
// the off state track of a Switch. was called `input`, which told you
|
||||
// nothing and collided with muted in every derived scheme.
|
||||
switchTrackInactive: switchTrackInactive ?? resolvedMuted,
|
||||
|
||||
ring: ring ?? _brightenForSelectionRing(primary),
|
||||
|
||||
chart1: chart1 ?? const Color(0xffff3352),
|
||||
chart2: chart2 ?? const Color(0xff8bdc00),
|
||||
chart3: chart3 ?? const Color(0xff2890ff),
|
||||
chart4: chart4 ?? const Color(0xffedba18),
|
||||
chart5: chart5 ?? const Color(0xffed5700),
|
||||
|
||||
chrome: resolvedChrome,
|
||||
|
||||
panelBorder: edge(background, 9.6),
|
||||
panelBorderHighlighted: raise(20.8),
|
||||
|
||||
rowHovered: raise(10.0),
|
||||
rowText: rowText ?? text(0.814),
|
||||
popoverItemHovered: raise(8.0),
|
||||
|
||||
// +6.2 over `card`, which is what a properties section is filled with -
|
||||
// NOT off the ground. Measured off the background it sat under a point
|
||||
// above its own fill and vanished into it.
|
||||
propertiesSectionBorder: edge(resolvedCard, 6.2),
|
||||
);
|
||||
}
|
||||
|
||||
final Brightness brightness;
|
||||
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
|
||||
/// Raised surface. Cards, and the fill behind a properties section.
|
||||
final Color card;
|
||||
|
||||
final Color popover;
|
||||
final Color popoverBorder;
|
||||
|
||||
// tooltips get their own pair rather than riding popover's - they sit on
|
||||
// top of everything and want more contrast than a panel-level surface.
|
||||
final Color tooltipBackground;
|
||||
final Color tooltipBorder;
|
||||
|
||||
final Color primary;
|
||||
final Color primaryHovered;
|
||||
final Color primaryForeground;
|
||||
final Color secondary;
|
||||
// secondary fill, hovered. used to be one hardcoded grey duplicated in
|
||||
// button.dart and select.dart, which meant the crimson/light schemes both
|
||||
// hovered to the same dark grey.
|
||||
final Color secondaryHovered;
|
||||
final Color secondaryForeground;
|
||||
final Color muted;
|
||||
final Color mutedForeground;
|
||||
final Color destructive;
|
||||
final Color border;
|
||||
final Color divider;
|
||||
|
||||
/// One step below the ground. Side panes, list backgrounds, anything
|
||||
/// recessed into the surface its sat on rather than lifted off it.
|
||||
final Color surfaceSunken;
|
||||
|
||||
// The control family: the fill and stroke every control shares - text
|
||||
// fields, selects, date inputs, outline and ghost buttons, checkboxes,
|
||||
// radios, the colour swatch. NOT text-field-only, whatever the old names
|
||||
// claimed.
|
||||
final Color controlFill;
|
||||
final Color controlFillHovered;
|
||||
final Color controlFillFocused;
|
||||
final Color controlBorder;
|
||||
|
||||
/// A Switch's track while its off. Its own slot because nothing else wants
|
||||
/// this colour and it used to squat on `input`.
|
||||
final Color switchTrackInactive;
|
||||
|
||||
final Color ring;
|
||||
final Color chart1;
|
||||
final Color chart2;
|
||||
final Color chart3;
|
||||
final Color chart4;
|
||||
final Color chart5;
|
||||
|
||||
// header/footer chrome, nudged off background so it reads as chrome.
|
||||
final Color chrome;
|
||||
|
||||
// borders of a content panel. The panel FILL is just `background` - all ten
|
||||
// hand authored schemes had them identical, so theres no slot for it.
|
||||
final Color panelBorder;
|
||||
final Color panelBorderHighlighted;
|
||||
|
||||
/// Hovered row, in a list or a menu. One slot: menuItemHovered and
|
||||
/// explorerRowHovered were the same colour in all ten schemes.
|
||||
final Color rowHovered;
|
||||
|
||||
/// Resting label/icon colour for a row. Was explorerRowText, and
|
||||
/// propertiesSectionLabel was the same colour in all ten schemes.
|
||||
final Color rowText;
|
||||
|
||||
// Interactive rows inside popovers. Kept separate from row hover while the
|
||||
// two interaction colours are being evaluated.
|
||||
final Color popoverItemHovered;
|
||||
|
||||
/// Outline of an object properties section. Measured off `card`, which is
|
||||
/// what fills one.
|
||||
final Color propertiesSectionBorder;
|
||||
|
||||
ColourScheme copyWith({
|
||||
Brightness? brightness,
|
||||
Color? background,
|
||||
Color? foreground,
|
||||
Color? card,
|
||||
Color? popover,
|
||||
Color? popoverBorder,
|
||||
Color? tooltipBackground,
|
||||
Color? tooltipBorder,
|
||||
Color? primary,
|
||||
Color? primaryHovered,
|
||||
Color? primaryForeground,
|
||||
Color? secondary,
|
||||
Color? secondaryHovered,
|
||||
Color? secondaryForeground,
|
||||
Color? muted,
|
||||
Color? mutedForeground,
|
||||
Color? destructive,
|
||||
Color? border,
|
||||
Color? divider,
|
||||
Color? surfaceSunken,
|
||||
Color? controlFill,
|
||||
Color? controlFillHovered,
|
||||
Color? controlFillFocused,
|
||||
Color? controlBorder,
|
||||
Color? switchTrackInactive,
|
||||
Color? ring,
|
||||
Color? chart1,
|
||||
Color? chart2,
|
||||
Color? chart3,
|
||||
Color? chart4,
|
||||
Color? chart5,
|
||||
Color? chrome,
|
||||
Color? panelBorder,
|
||||
Color? panelBorderHighlighted,
|
||||
Color? rowHovered,
|
||||
Color? rowText,
|
||||
Color? popoverItemHovered,
|
||||
Color? propertiesSectionBorder,
|
||||
}) {
|
||||
return ColourScheme(
|
||||
brightness: brightness ?? this.brightness,
|
||||
background: background ?? this.background,
|
||||
foreground: foreground ?? this.foreground,
|
||||
card: card ?? this.card,
|
||||
popover: popover ?? this.popover,
|
||||
popoverBorder: popoverBorder ?? this.popoverBorder,
|
||||
tooltipBackground: tooltipBackground ?? this.tooltipBackground,
|
||||
tooltipBorder: tooltipBorder ?? this.tooltipBorder,
|
||||
primary: primary ?? this.primary,
|
||||
primaryHovered: primaryHovered ?? this.primaryHovered,
|
||||
primaryForeground: primaryForeground ?? this.primaryForeground,
|
||||
secondary: secondary ?? this.secondary,
|
||||
secondaryHovered: secondaryHovered ?? this.secondaryHovered,
|
||||
secondaryForeground: secondaryForeground ?? this.secondaryForeground,
|
||||
muted: muted ?? this.muted,
|
||||
mutedForeground: mutedForeground ?? this.mutedForeground,
|
||||
destructive: destructive ?? this.destructive,
|
||||
border: border ?? this.border,
|
||||
divider: divider ?? this.divider,
|
||||
surfaceSunken: surfaceSunken ?? this.surfaceSunken,
|
||||
controlFill: controlFill ?? this.controlFill,
|
||||
controlFillHovered: controlFillHovered ?? this.controlFillHovered,
|
||||
controlFillFocused: controlFillFocused ?? this.controlFillFocused,
|
||||
controlBorder: controlBorder ?? this.controlBorder,
|
||||
switchTrackInactive: switchTrackInactive ?? this.switchTrackInactive,
|
||||
ring: ring ?? this.ring,
|
||||
chart1: chart1 ?? this.chart1,
|
||||
chart2: chart2 ?? this.chart2,
|
||||
chart3: chart3 ?? this.chart3,
|
||||
chart4: chart4 ?? this.chart4,
|
||||
chart5: chart5 ?? this.chart5,
|
||||
chrome: chrome ?? this.chrome,
|
||||
panelBorder: panelBorder ?? this.panelBorder,
|
||||
panelBorderHighlighted:
|
||||
panelBorderHighlighted ?? this.panelBorderHighlighted,
|
||||
rowHovered: rowHovered ?? this.rowHovered,
|
||||
rowText: rowText ?? this.rowText,
|
||||
popoverItemHovered: popoverItemHovered ?? this.popoverItemHovered,
|
||||
propertiesSectionBorder:
|
||||
propertiesSectionBorder ?? this.propertiesSectionBorder,
|
||||
);
|
||||
}
|
||||
|
||||
// Accent override - swaps primary/ring to the given accent colour, and picks a
|
||||
// readable foreground for it. Mirrors what shadcns recolor() did. Pass null
|
||||
// (the "none" accent) to leave the scheme untouched.
|
||||
ColourScheme withAccent(Color? accent) {
|
||||
if (accent == null) return this;
|
||||
return copyWith(
|
||||
primary: accent,
|
||||
primaryHovered: accent,
|
||||
primaryForeground: _contrastColor(accent),
|
||||
ring: accent,
|
||||
);
|
||||
}
|
||||
|
||||
static ColourScheme lerp(ColourScheme a, ColourScheme b, double t) {
|
||||
if (t <= 0) return a;
|
||||
if (t >= 1) return b;
|
||||
Color c(Color x, Color y) => Color.lerp(x, y, t)!;
|
||||
return ColourScheme(
|
||||
brightness: t < 0.5 ? a.brightness : b.brightness,
|
||||
background: c(a.background, b.background),
|
||||
foreground: c(a.foreground, b.foreground),
|
||||
card: c(a.card, b.card),
|
||||
popover: c(a.popover, b.popover),
|
||||
popoverBorder: c(a.popoverBorder, b.popoverBorder),
|
||||
tooltipBackground: c(a.tooltipBackground, b.tooltipBackground),
|
||||
tooltipBorder: c(a.tooltipBorder, b.tooltipBorder),
|
||||
primary: c(a.primary, b.primary),
|
||||
primaryHovered: c(a.primaryHovered, b.primaryHovered),
|
||||
primaryForeground: c(a.primaryForeground, b.primaryForeground),
|
||||
secondary: c(a.secondary, b.secondary),
|
||||
secondaryHovered: c(a.secondaryHovered, b.secondaryHovered),
|
||||
secondaryForeground: c(a.secondaryForeground, b.secondaryForeground),
|
||||
muted: c(a.muted, b.muted),
|
||||
mutedForeground: c(a.mutedForeground, b.mutedForeground),
|
||||
destructive: c(a.destructive, b.destructive),
|
||||
border: c(a.border, b.border),
|
||||
divider: c(a.divider, b.divider),
|
||||
surfaceSunken: c(a.surfaceSunken, b.surfaceSunken),
|
||||
controlFill: c(a.controlFill, b.controlFill),
|
||||
controlFillHovered: c(a.controlFillHovered, b.controlFillHovered),
|
||||
controlFillFocused: c(a.controlFillFocused, b.controlFillFocused),
|
||||
controlBorder: c(a.controlBorder, b.controlBorder),
|
||||
switchTrackInactive: c(a.switchTrackInactive, b.switchTrackInactive),
|
||||
ring: c(a.ring, b.ring),
|
||||
chart1: c(a.chart1, b.chart1),
|
||||
chart2: c(a.chart2, b.chart2),
|
||||
chart3: c(a.chart3, b.chart3),
|
||||
chart4: c(a.chart4, b.chart4),
|
||||
chart5: c(a.chart5, b.chart5),
|
||||
chrome: c(a.chrome, b.chrome),
|
||||
panelBorder: c(a.panelBorder, b.panelBorder),
|
||||
panelBorderHighlighted: c(
|
||||
a.panelBorderHighlighted,
|
||||
b.panelBorderHighlighted,
|
||||
),
|
||||
rowHovered: c(a.rowHovered, b.rowHovered),
|
||||
rowText: c(a.rowText, b.rowText),
|
||||
popoverItemHovered: c(a.popoverItemHovered, b.popoverItemHovered),
|
||||
propertiesSectionBorder: c(
|
||||
a.propertiesSectionBorder,
|
||||
b.propertiesSectionBorder,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [base]'s lightness in CIE L*, 0 (black) to 100 (white).
|
||||
double lstarOf(Color base) => _lstar(base);
|
||||
|
||||
/// [base] moved [points] in CIE L*, keeping its hue and saturation.
|
||||
///
|
||||
/// Reflects rather than clamps when theres no room that way: a colour that
|
||||
/// cant get [points] lighter gets [points] darker instead, keeping the size of
|
||||
/// the step and losing only its direction. A clamp doesnt shorten a step, it
|
||||
/// deletes it - two slots asking for +6 and +11 against a near-white ground
|
||||
/// both land on white and a distinction that exists in every other scheme is
|
||||
/// gone.
|
||||
///
|
||||
/// This is the same step [ColourScheme.derive] is built out of, exposed so an
|
||||
/// app deriving extra slots of its own (Arcs & Angles' canvas colours) walks
|
||||
/// the identical ladder rather than reinventing a near-miss of it.
|
||||
Color shiftLstar(Color base, double points) {
|
||||
final l = _lstar(base);
|
||||
final wanted = l + points;
|
||||
return _atLstar(base, (wanted > 100 || wanted < 0) ? l - points : wanted);
|
||||
}
|
||||
|
||||
// chrome is the deepest recessed slot the shared scheme has, so its want sets
|
||||
// the squeeze factor for every other one.
|
||||
const double _deepestSink = 12.6;
|
||||
|
||||
// ── CIE L* ───────────────────────────────────────────────────────────────
|
||||
// The offsets above are all in L*, which is perceptually uniform - five
|
||||
// points looks like the same step whether youre near black or near white.
|
||||
// HSL lightness, which this used to use, very much does not.
|
||||
|
||||
double _channel(double v) =>
|
||||
v <= 0.04045 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
||||
|
||||
double _lstar(Color c) {
|
||||
final y =
|
||||
0.2126 * _channel(c.r) + 0.7152 * _channel(c.g) + 0.0722 * _channel(c.b);
|
||||
return y > 0.008856 ? 116 * math.pow(y, 1 / 3).toDouble() - 16 : 903.3 * y;
|
||||
}
|
||||
|
||||
// [base]'s hue and saturation at the given L*.
|
||||
//
|
||||
// Theres no closed form for this that keeps HSL saturation fixed, so it
|
||||
// bisects on HSL lightness instead - which is fine, its monotonic in
|
||||
// luminance and this runs once per scheme at startup, not per frame.
|
||||
Color _atLstar(Color base, double target) {
|
||||
final hsl = HSLColor.fromColor(base);
|
||||
final want = target.clamp(0.0, 100.0);
|
||||
|
||||
var lo = 0.0;
|
||||
var hi = 1.0;
|
||||
for (var i = 0; i < 18; i++) {
|
||||
final mid = (lo + hi) / 2;
|
||||
if (_lstar(hsl.withLightness(mid).toColor()) < want) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return hsl.withLightness((lo + hi) / 2).toColor();
|
||||
}
|
||||
|
||||
// flips lightness to get a readable foreground on a given colour. same idea as
|
||||
// shadcns getContrastColor (full luminance contrast).
|
||||
Color _contrastColor(Color on) {
|
||||
final hsl = HSLColor.fromColor(on);
|
||||
final l = hsl.lightness;
|
||||
final target = l >= 0.5 ? 0.0 : 1.0;
|
||||
// nudge toward the target rather than pure black/white so it doesnt look harsh
|
||||
final mixed = lerpDouble(l, target, 1.0)!;
|
||||
return hsl.withLightness(mixed.clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
|
||||
// a focus ring wants to read punchier than a resting "primary" swatch -
|
||||
// lighter and more saturated, so it pops against whatevers behind it instead
|
||||
// of just matching a button colour.
|
||||
Color _brightenForSelectionRing(Color colour) {
|
||||
final hsl = HSLColor.fromColor(colour);
|
||||
return hsl
|
||||
.withSaturation((hsl.saturation + 0.16).clamp(0.0, 1.0))
|
||||
.withLightness((hsl.lightness + 0.13).clamp(0.0, 0.78))
|
||||
.toColor();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
|
||||
import "package:garage_ui/theme/theme_data.dart";
|
||||
|
||||
export "package:garage_ui/theme/theme_data.dart";
|
||||
export "package:garage_ui/theme/colour_scheme.dart";
|
||||
export "package:garage_ui/theme/typography.dart";
|
||||
|
||||
// The apps theme. Hands the theme data down via a plain inherited scope (no
|
||||
// animated wrapper, no per frame ColorScheme.lerp flattening the whole thing -
|
||||
// this is the bit that used to feel second class), AND establishes a sane
|
||||
// DefaultTextStyle + IconTheme so bare Text/Icon dont fall back to flutter's
|
||||
// yellow-underline "unstyled" debug look (MaterialApp only styles text inside a
|
||||
// Material, and our panels arent Material).
|
||||
class GarageTheme extends StatelessWidget {
|
||||
const GarageTheme({super.key, required this.data, required this.child});
|
||||
|
||||
final ThemeData data;
|
||||
final Widget child;
|
||||
|
||||
static ThemeData of(BuildContext context) {
|
||||
final t = maybeOf(context);
|
||||
assert(
|
||||
t != null,
|
||||
"No GarageTheme found in context. Wrap the app in an GarageTheme.",
|
||||
);
|
||||
return t!;
|
||||
}
|
||||
|
||||
static ThemeData? maybeOf(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<_GarageThemeScope>()
|
||||
?.data;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = data.colorScheme;
|
||||
return _GarageThemeScope(
|
||||
data: data,
|
||||
child: DefaultTextStyle(
|
||||
// the one app-wide UI font, declared right here at the top of the tree.
|
||||
// everything below that doesnt override fontFamily inherits Geist.
|
||||
// (canvas-painted text - station labels, watermark - sits outside the
|
||||
// widget tree so it isnt touched by this.)
|
||||
style: GoogleFonts.geist(
|
||||
color: cs.foreground,
|
||||
// off the density, not a literal times scaling. this was the third
|
||||
// independent source of text size in the package - controls read
|
||||
// density.fontSize, the .xSmall()/.small()/.large() ladder now reads
|
||||
// it too, and this floated free at 11 * scaling. Retune fontSize and
|
||||
// page copy no longer stays behind while the controls move.
|
||||
//
|
||||
// fontSize, NOT textXs. The ladder above the control font is a set
|
||||
// of MULTIPLIERS (xs 1.1x, sm 1.4x, lg 1.8x), so what textXs means
|
||||
// depends on the tier: at A&A's 10 its 11, a hair over the control
|
||||
// font and harmless, and at the product tier's 16 its 18 - body copy
|
||||
// rendering BIGGER than the text inside the buttons and fields next
|
||||
// to it. Thats backwards, and its what made the product surfaces
|
||||
// read as oversized even with their control geometry correct.
|
||||
//
|
||||
// Body copy is the control font. A sentence and the text in the box
|
||||
// under it are the same size, at every tier, by construction.
|
||||
fontSize: data.density.fontSize,
|
||||
// the important bit - kills the yellow underline.
|
||||
decoration: TextDecoration.none,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
color: cs.foreground,
|
||||
size: data.iconTheme.medium.size,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GarageThemeScope extends InheritedWidget {
|
||||
const _GarageThemeScope({required this.data, required super.child});
|
||||
|
||||
final ThemeData data;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_GarageThemeScope oldWidget) =>
|
||||
oldWidget.data != data;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import "dart:ui" show Color;
|
||||
|
||||
import "package:flutter/widgets.dart" show Brightness;
|
||||
|
||||
import "package:garage_ui/theme/colour_scheme.dart";
|
||||
|
||||
/// Ready-made schemes, so a new app can look like a Garage app on line one.
|
||||
///
|
||||
/// Standing one up used to mean authoring 54 colours by hand before writing
|
||||
/// any app code. These are the neutral pair, built through
|
||||
/// [ColourScheme.derive] from four colours each — which is also the worked
|
||||
/// example of how to make your own.
|
||||
///
|
||||
/// ```dart
|
||||
/// GarageApp.router(
|
||||
/// theme: ThemeData(colorScheme: GarageSchemes.dark),
|
||||
/// ...
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// Want your own hue? Change [ColourScheme.derive]'s `primary` and leave the
|
||||
/// rest. Want one slot exact? Every derived slot has an override.
|
||||
abstract final class GarageSchemes {
|
||||
/// The neutral dark scheme. Same core Arcs & Angles' zinc is built on.
|
||||
static final ColourScheme dark = ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff303030),
|
||||
foreground: const Color(0xffe6e6e6),
|
||||
primary: const Color(0xff4772b3),
|
||||
);
|
||||
|
||||
/// The neutral light scheme — the same blue on a faintly cool near-white.
|
||||
static final ColourScheme light = ColourScheme.derive(
|
||||
brightness: Brightness.light,
|
||||
background: const Color(0xfff2f2f4),
|
||||
foreground: const Color(0xff1a1a1c),
|
||||
primary: const Color(0xff3a63a1),
|
||||
);
|
||||
|
||||
/// Carbon. True black, no chroma at all - built for oled panels, and the
|
||||
/// scheme the Garage apps actually run.
|
||||
///
|
||||
/// It lives here rather than in each app because it didnt: the hub and Arcs
|
||||
/// & Angles each kept their own copy and they drifted apart in twenty five
|
||||
/// of fifty two slots before anyone noticed. One definition cant.
|
||||
///
|
||||
/// Carbon has 3.6 L* of room under its ground, against zinc's 19.9, so every
|
||||
/// recessed rung squeezes into what there is - which is why chrome lands on
|
||||
/// the floor without being told to. The pins are where carbon wants
|
||||
/// something other than what the ladder gives it.
|
||||
static final ColourScheme carbon = ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff0d0d0d),
|
||||
foreground: const Color(0xfff2f2f2),
|
||||
|
||||
// no accent hue anywhere - primary is just a near white fill
|
||||
primary: const Color(0xffe8e8e8),
|
||||
primaryForeground: const Color(0xff000000),
|
||||
ring: const Color(0xffffffff),
|
||||
destructive: const Color(0xffc43333),
|
||||
|
||||
// the ladder puts these a shade off the floor. carbon wants the floor -
|
||||
// the gutter between panels is the whole look.
|
||||
popover: const Color(0xff000000),
|
||||
controlFillFocused: const Color(0xff000000),
|
||||
|
||||
// above the ground rather than below it, which is what carbon has always
|
||||
// done with muted. theres barely any "below" left to use.
|
||||
muted: const Color(0xff0f0f0f),
|
||||
controlFill: const Color(0xff080808),
|
||||
tooltipBackground: const Color(0xff080808),
|
||||
|
||||
// NOT the ladder's +11.8 over the fill. Carbon cant recess a field far
|
||||
// enough to read as recessed, so the stroke does all the separating and
|
||||
// has to be the strongest thing on the control, not the weakest.
|
||||
controlBorder: const Color(0xff2a2a2a),
|
||||
|
||||
chart1: const Color(0xfff45b69),
|
||||
chart2: const Color(0xff86d957),
|
||||
chart3: const Color(0xff62a7ff),
|
||||
chart4: const Color(0xffffc857),
|
||||
chart5: const Color(0xffff8c42),
|
||||
);
|
||||
|
||||
/// Whichever of the pair matches [brightness].
|
||||
static ColourScheme of(Brightness brightness) =>
|
||||
brightness == Brightness.dark ? dark : light;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import "dart:ui" show Color, ImageFilter;
|
||||
|
||||
import "package:flutter/painting.dart" show HSLColor;
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
import "package:garage_ui/theme/garage_theme.dart";
|
||||
|
||||
// small pile of helpers the GarageUI widgets used to pull off the shadcn barrel.
|
||||
// theyre generic, nothing shadcn specific, so we just keep our own copies.
|
||||
|
||||
// shadcns Color helpers that the widgets lean on.
|
||||
extension ColorExtension on Color {
|
||||
// scale the alpha channel by [factor] (0..1). shadcns scaleAlpha.
|
||||
Color scaleAlpha(double factor) {
|
||||
return withValues(alpha: (a * factor).clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
// a readable foreground for this colour - flips lightness. shadcns getContrastColor.
|
||||
Color getContrastColor([double luminanceContrast = 1]) {
|
||||
final hsl = HSLColor.fromColor(this);
|
||||
final l = hsl.lightness;
|
||||
final target = l >= 0.5
|
||||
? l - (l * luminanceContrast)
|
||||
: l + ((1 - l) * luminanceContrast);
|
||||
return hsl.withLightness(target.clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
}
|
||||
|
||||
// default anim duration shadcn used all over (hover/colour transitions etc).
|
||||
const Duration kDefaultDuration = Duration(milliseconds: 150);
|
||||
|
||||
// widget value wins, then theme value, then the hard default. shadcns styleValue.
|
||||
T styleValue<T>({T? widgetValue, T? themeValue, required T defaultValue}) {
|
||||
return widgetValue ?? themeValue ?? defaultValue;
|
||||
}
|
||||
|
||||
// we dropped shadcns density-insets system, so this is now just a passthrough -
|
||||
// the GarageUI widgets only ever hand it plain EdgeInsets anyway.
|
||||
EdgeInsetsGeometry resolveEdgeInsets(
|
||||
EdgeInsetsGeometry padding,
|
||||
double basePadding,
|
||||
) {
|
||||
return padding;
|
||||
}
|
||||
|
||||
// shrinks a border radius by the border width so an inner surface tucks neatly
|
||||
// inside its border. shadcns subtractByBorder.
|
||||
BorderRadius subtractByBorder(BorderRadius radius, double borderWidth) {
|
||||
Radius sub(Radius r) => Radius.elliptical(
|
||||
(r.x - borderWidth).clamp(0.0, double.infinity),
|
||||
(r.y - borderWidth).clamp(0.0, double.infinity),
|
||||
);
|
||||
return BorderRadius.only(
|
||||
topLeft: sub(radius.topLeft),
|
||||
topRight: sub(radius.topRight),
|
||||
bottomLeft: sub(radius.bottomLeft),
|
||||
bottomRight: sub(radius.bottomRight),
|
||||
);
|
||||
}
|
||||
|
||||
// used to be a density-aware Padding in shadcn. our padding is already resolved
|
||||
// by the time it gets here, so this is just a Padding.
|
||||
class DensityContainerPadding extends StatelessWidget {
|
||||
const DensityContainerPadding({
|
||||
super.key,
|
||||
required this.padding,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final EdgeInsetsGeometry padding;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(padding: padding, child: child);
|
||||
}
|
||||
|
||||
// shadcn used this to tell whether a surface was being rendered inside a sheet
|
||||
// so it could drop its own rounding/border. the app doesnt use sheets, so its
|
||||
// always false.
|
||||
class SheetOverlayHandler {
|
||||
const SheetOverlayHandler._();
|
||||
static bool isSheetOverlay(BuildContext context) => false;
|
||||
}
|
||||
|
||||
// draws a focus ring around a child when [focused]. shadcns FocusOutline, cut
|
||||
// down to what the text field needs.
|
||||
class FocusOutline extends StatelessWidget {
|
||||
const FocusOutline({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.focused,
|
||||
this.borderRadius,
|
||||
this.color,
|
||||
this.width = 1.0,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool focused;
|
||||
final BorderRadiusGeometry? borderRadius;
|
||||
final Color? color;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!focused) return child;
|
||||
final ring = color ?? GarageTheme.of(context).colorScheme.ring;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
// draw the ring OUTSIDE the box so it doesnt inset the child and
|
||||
// change the field size when focus comes and goes.
|
||||
border: Border.all(
|
||||
color: ring,
|
||||
width: width,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// blurs whatever is behind the child (glassmorphism on popovers/cards). ported
|
||||
// straight from shadcns outlined_container.dart.
|
||||
class SurfaceBlur extends StatefulWidget {
|
||||
const SurfaceBlur({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.surfaceBlur,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final double? surfaceBlur;
|
||||
final BorderRadiusGeometry? borderRadius;
|
||||
|
||||
@override
|
||||
State<SurfaceBlur> createState() => _SurfaceBlurState();
|
||||
}
|
||||
|
||||
class _SurfaceBlurState extends State<SurfaceBlur> {
|
||||
final GlobalKey _mainContainerKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.surfaceBlur == null || widget.surfaceBlur! <= 0) {
|
||||
return KeyedSubtree(key: _mainContainerKey, child: widget.child);
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: widget.borderRadius ?? BorderRadius.zero,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: widget.surfaceBlur!,
|
||||
sigmaY: widget.surfaceBlur!,
|
||||
),
|
||||
// needs a child or it wont actually blur anything
|
||||
child: const SizedBox(),
|
||||
),
|
||||
),
|
||||
),
|
||||
KeyedSubtree(key: _mainContainerKey, child: widget.child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import "package:flutter/foundation.dart"
|
||||
show TargetPlatform, defaultTargetPlatform;
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
import "package:garage_ui/theme/colour_scheme.dart";
|
||||
import "package:garage_ui/theme/typography.dart";
|
||||
|
||||
/// The two control tiers. One type for both levels: hand it to [ThemeData] to
|
||||
/// set the app-wide density, or to any control to override that control.
|
||||
///
|
||||
/// Replaces the pair this used to be - a `DensityMode` enum on the theme and a
|
||||
/// separate `ControlDensity` class on widgets - which modelled the same two
|
||||
/// values twice, in two shapes, and named the widget-level one after a single
|
||||
/// widget despite Select, Menubar, MenuButton, MenuPopup, TextField and
|
||||
/// TabView all taking it.
|
||||
enum ControlDensity {
|
||||
compact,
|
||||
normal;
|
||||
|
||||
bool get isCompact => this == ControlDensity.compact;
|
||||
|
||||
/// The canonical token set for this tier, ignoring any theme tuning.
|
||||
Density get canonical =>
|
||||
isCompact ? const Density.compact() : const Density.normal();
|
||||
|
||||
/// The tier the theme is currently set to.
|
||||
static ControlDensity of(ThemeData theme) => theme.density.control;
|
||||
|
||||
/// The token set a control on this tier should read.
|
||||
///
|
||||
/// When the tier matches the theme's, the theme's own [Density] is used, so
|
||||
/// a tuned one survives. When a control asks for the other tier, it falls
|
||||
/// back to that tier's canonical tokens.
|
||||
Density tokens(ThemeData theme) =>
|
||||
this == theme.density.control ? theme.density : canonical;
|
||||
|
||||
EdgeInsets resolve(ThemeData theme) => tokens(theme).buttonPadding;
|
||||
|
||||
/// Padding for an icon-only control, collapsed to its smallest side so the
|
||||
/// control comes out square rather than inheriting the asymmetric
|
||||
/// horizontal padding.
|
||||
EdgeInsets resolveIcon(ThemeData theme) => _squarePadding(resolve(theme));
|
||||
}
|
||||
|
||||
EdgeInsets _squarePadding(EdgeInsets p) {
|
||||
final side = p.vertical < p.horizontal ? p.vertical / 2 : p.horizontal / 2;
|
||||
return EdgeInsets.all(side);
|
||||
}
|
||||
|
||||
/// The app-wide UI density.
|
||||
///
|
||||
/// ONE RULE: the fields on this class are the only hand-set numbers in the
|
||||
/// control system. Everything else - line box, icon size, every vertical
|
||||
/// padding, every control height - is a getter derived from them. If you find
|
||||
/// yourself typing a pixel height anywhere else, it belongs here instead.
|
||||
///
|
||||
/// The heights are the configurable part. You say how tall a control should be
|
||||
/// and the padding falls out of it:
|
||||
///
|
||||
/// lineBox = fontSize * lineHeight
|
||||
/// controlPaddingY = (controlHeight - lineBox) / 2
|
||||
///
|
||||
/// NOT the other way round. Tuning padding until a height came out right is
|
||||
/// what produced four different control heights, a `Transform.translate` in two
|
||||
/// widgets, and 23/27 re-typed as literals in the explorer.
|
||||
///
|
||||
/// `lineBox` needs no font metrics: when a TextStyle carries an explicit
|
||||
/// `height`, Flutter sizes the line box to exactly `fontSize * height` and does
|
||||
/// not consult the font. That is what makes this whole chain deterministic and
|
||||
/// testable - a style with a null `height` measures 1.0x in the test host and
|
||||
/// ~1.25x in the app, which is how a ~26px TextField once passed a suite of
|
||||
/// tests that all asserted 23.
|
||||
class Density {
|
||||
const Density({
|
||||
this.control = ControlDensity.compact,
|
||||
|
||||
// ---- type ----
|
||||
this.fontSize = 10.0,
|
||||
this.lineHeight = 1.1,
|
||||
|
||||
// ---- height targets (configure these; paddings derive from them) ----
|
||||
this.controlHeight = 23.0,
|
||||
this.menuRowHeight = 20.0,
|
||||
this.popupRowHeight = 20.0,
|
||||
this.popupMaxRows = 12,
|
||||
this.explorerRowHeight = 22.0,
|
||||
|
||||
// ---- horizontal + misc primitives ----
|
||||
this.buttonPaddingX = 10.0,
|
||||
this.controlGap = 4.0,
|
||||
this.controlBorderWidth = 1.0,
|
||||
this.explorerRowBasePadding = 4.0,
|
||||
this.explorerRowEndPadding = 10.0,
|
||||
this.listRowIndent = 6.0,
|
||||
|
||||
// ---- container-level spacing (panels, popovers, dialogs) ----
|
||||
this.containerGap = 8.0,
|
||||
this.containerPadding = 16.0,
|
||||
|
||||
// ---- optical ----
|
||||
this.controlTextOffset = 0.0,
|
||||
});
|
||||
|
||||
/// The compact tier, said out loud. Same as the unnamed constructor's
|
||||
/// defaults - which is exactly why it exists, because `Density()` silently
|
||||
/// meaning "compact" was a trap.
|
||||
const Density.compact() : this();
|
||||
|
||||
const Density.normal()
|
||||
: control = ControlDensity.normal,
|
||||
fontSize = 10.0,
|
||||
lineHeight = 1.1,
|
||||
controlHeight = 27.0,
|
||||
menuRowHeight = 26.0,
|
||||
popupRowHeight = 26.0,
|
||||
popupMaxRows = 12,
|
||||
explorerRowHeight = 30.0,
|
||||
buttonPaddingX = 12.0,
|
||||
controlGap = 5.0,
|
||||
controlBorderWidth = 1.0,
|
||||
explorerRowBasePadding = 8.0,
|
||||
explorerRowEndPadding = 14.0,
|
||||
listRowIndent = 12.0,
|
||||
containerGap = 10.0,
|
||||
containerPadding = 20.0,
|
||||
controlTextOffset = 0.0;
|
||||
|
||||
/// The product tier - for the Garage web apps rather than for A&A.
|
||||
///
|
||||
/// compact and normal are both answers to "a properties inspector has to sit
|
||||
/// beside a viewport without eating it", which is why they share a 10px font
|
||||
/// and differ only in control geometry. An app with no canvas inherits that
|
||||
/// economy for nothing, so this tier scales the TYPE as well: 12px control
|
||||
/// font, 33px controls, and a gap ladder off a base of 5.
|
||||
///
|
||||
/// `control` stays [ControlDensity.normal] deliberately. That enum is the
|
||||
/// two-way geometry switch widgets branch on (`isCompact`), not a name for
|
||||
/// the tier, and A&A switches over it exhaustively in its settings UI.
|
||||
///
|
||||
/// 12 * 1.25 = 15 line box, (33 - 15) / 2 = 9 padding. both whole, so the
|
||||
/// control geometry stays exact - see [lineBox].
|
||||
const Density.product()
|
||||
: control = ControlDensity.normal,
|
||||
fontSize = 12.0,
|
||||
lineHeight = 1.25,
|
||||
controlHeight = 33.0,
|
||||
menuRowHeight = 33.0,
|
||||
popupRowHeight = 33.0,
|
||||
popupMaxRows = 10,
|
||||
explorerRowHeight = 29.0,
|
||||
buttonPaddingX = 10.0,
|
||||
controlGap = 5.0,
|
||||
controlBorderWidth = 1.0,
|
||||
explorerRowBasePadding = 8.0,
|
||||
explorerRowEndPadding = 12.0,
|
||||
listRowIndent = 10.0,
|
||||
containerGap = 10.0,
|
||||
containerPadding = 20.0,
|
||||
controlTextOffset = 0.0;
|
||||
|
||||
/// Which tier this token set represents.
|
||||
final ControlDensity control;
|
||||
|
||||
// ---- type ----
|
||||
|
||||
/// Control font size. The app is a fixed compact desktop tool, so these are
|
||||
/// final pixel values and are deliberately NOT multiplied by `scaling`.
|
||||
final double fontSize;
|
||||
|
||||
/// Line box as a multiple of [fontSize]. Pinning this is what makes control
|
||||
/// geometry font-independent - see the class doc.
|
||||
final double lineHeight;
|
||||
|
||||
// ---- height targets ----
|
||||
|
||||
/// Button / text field / select trigger / icon button.
|
||||
final double controlHeight;
|
||||
|
||||
/// Menu rows (MenuButton and friends).
|
||||
///
|
||||
/// Happens to equal [popupRowHeight] in both densities today - menus and
|
||||
/// select popups are the same kind of surface. Kept as two tokens anyway so
|
||||
/// one can move without dragging the other along.
|
||||
final double menuRowHeight;
|
||||
|
||||
/// Rows inside a select popup.
|
||||
final double popupRowHeight;
|
||||
|
||||
/// How many rows a select popup shows before it starts scrolling. A count,
|
||||
/// not a pixel value - the height falls out of it via [popupMaxHeight], so a
|
||||
/// denser popup gets shorter rather than showing more of them.
|
||||
final int popupMaxRows;
|
||||
|
||||
/// Explorer tree rows.
|
||||
final double explorerRowHeight;
|
||||
|
||||
// ---- horizontal + misc ----
|
||||
|
||||
final double buttonPaddingX;
|
||||
|
||||
/// Gap between a control's leading/trailing icon and its label. Sits INSIDE
|
||||
/// the control, so it is tighter than the outer padding on purpose.
|
||||
final double controlGap;
|
||||
|
||||
final double controlBorderWidth;
|
||||
|
||||
/// Leading indent applied per depth level in the explorer tree.
|
||||
final double explorerRowBasePadding;
|
||||
|
||||
/// Trailing inset on an explorer row, so the hide toggle isnt sat right
|
||||
/// under the scrollbar thumb.
|
||||
final double explorerRowEndPadding;
|
||||
|
||||
/// Leading inset on a flat list row, before its icon. Distinct from
|
||||
/// [explorerRowBasePadding], which is a per-depth indent in a tree.
|
||||
///
|
||||
/// Authored from the values the Lines slots list was already branching on by
|
||||
/// hand. It and the explorer disagree about this inset (6/12 vs 4/8) and
|
||||
/// always have - reconciling them is a visual decision, not a refactor.
|
||||
final double listRowIndent;
|
||||
|
||||
// ---- container-level ----
|
||||
|
||||
/// Spacing between elements in a panel / popover / dialog. This is layout
|
||||
/// spacing, NOT control-internal spacing - reach for [controlGap] inside a
|
||||
/// control. (Replaces the old shadcn-inherited `baseGap`.)
|
||||
final double containerGap;
|
||||
|
||||
/// Padding inside a panel / popover / dialog. (Replaces `baseContentPadding`
|
||||
/// and `baseContainerPadding`, which always held the same value.)
|
||||
final double containerPadding;
|
||||
|
||||
// ---- optical ----
|
||||
|
||||
/// Downward nudge applied to control content so it sits on its optical
|
||||
/// centre rather than its geometric one. Negative moves it up.
|
||||
///
|
||||
/// This is the one value the maths cannot settle on its own - the line box is
|
||||
/// exact, but where the ink sits inside it depends on the font's
|
||||
/// ascent/descent split. So it is a judgement made by eye, once, here. It
|
||||
/// used to be `Offset(0, 1)` hardcoded in Button and `Offset(0, 2)` in
|
||||
/// Select, which is two judgements that disagreed.
|
||||
///
|
||||
/// Currently 0 - i.e. the geometric centre is what looks right in Geist at
|
||||
/// this size. Keep the token even so: it is the knob, and a zero here costs
|
||||
/// nothing because the controls skip the transform entirely when it is 0.
|
||||
final double controlTextOffset;
|
||||
|
||||
// =========================================================================
|
||||
// derived - do not hand-set any of these, and do not re-derive them at a
|
||||
// call site
|
||||
// =========================================================================
|
||||
|
||||
/// Height of one line of control text: `fontSize * lineHeight`, rounded to a
|
||||
/// whole pixel.
|
||||
///
|
||||
/// Measured, not assumed. Against Georgia and Andale Mono (both natural ratio
|
||||
/// 1.10) and the test host's fallback (1.00): when the product is a whole
|
||||
/// number the engine lays the line box out at exactly that in all three, so
|
||||
/// the font's own ascent/descent genuinely do not participate. That is what
|
||||
/// makes control geometry font-independent and testable.
|
||||
///
|
||||
/// When the product is fractional the engine rounds it, and at a .5 tie the
|
||||
/// direction is FONT-DEPENDENT (13.5 came out 14 in Andale, 13 in Georgia).
|
||||
/// So keep `fontSize * lineHeight` on a whole number - 10 * 1.1 = 11 does -
|
||||
/// and every height below is exact. The rounding here keeps the derivation
|
||||
/// honest for other configs rather than quietly missing the target by a
|
||||
/// fraction of a pixel.
|
||||
double get lineBox => (fontSize * lineHeight).roundToDouble();
|
||||
|
||||
/// Control icons, sized against the TEXT rather than the line box.
|
||||
///
|
||||
/// This used to be `lineBox` - 15px at product - on the reasoning that an
|
||||
/// icon-only control and a text control then come out the same height for
|
||||
/// free. That holds, but it isnt what the eye measures: lucide glyphs fill
|
||||
/// their box nearly edge to edge while a 12px font has a cap height around
|
||||
/// 8.5px, so a line-box icon reads about 70% taller than the letters beside
|
||||
/// it and every button with an icon in it looked slightly wrong.
|
||||
///
|
||||
/// Level with the font. 1.1x was the first attempt at the optical match and
|
||||
/// still read a shade heavy next to the label beside it. Control HEIGHT is
|
||||
/// unaffected either way: that comes from controlHeight, not from whats
|
||||
/// inside.
|
||||
double get iconSize => fontSize;
|
||||
|
||||
double get controlPaddingY => (controlHeight - lineBox) / 2;
|
||||
double get menuRowPaddingY => (menuRowHeight - lineBox) / 2;
|
||||
double get popupRowPaddingY => (popupRowHeight - lineBox) / 2;
|
||||
double get explorerRowPaddingY => (explorerRowHeight - lineBox) / 2;
|
||||
|
||||
/// Height of a chrome bar - an editor's header or footer.
|
||||
///
|
||||
/// One [controlGap] above the control and one below, which is [gapMd] all
|
||||
/// told. A bar sized any tighter than that isnt giving its contents a
|
||||
/// margin so much as a haircut: the old hardcoded 30 left 1.5px over a
|
||||
/// normal-tier control and would have been SHORTER than a product-tier one,
|
||||
/// so a button in the header didnt fit the header.
|
||||
///
|
||||
/// Matching the vertical margin to the horizontal gap is the whole point -
|
||||
/// a row of buttons then sits in an even field instead of one thats
|
||||
/// generous side to side and tight top to bottom.
|
||||
///
|
||||
/// compact 23 + 8 = 31, normal 27 + 10 = 37, product 33 + 10 = 43
|
||||
double get chromeBarHeight => controlHeight + gapMd;
|
||||
|
||||
/// Cap on a select popup's list. Counts rows only - the list's own padding
|
||||
/// (and a search field, when there is one) sits on top, so the last row
|
||||
/// clips slightly rather than landing flush. Thats deliberate: a half row
|
||||
/// showing is the cheapest "theres more below" hint there is.
|
||||
///
|
||||
/// Was `kDefaultSelectMaxHeight = 240.0` in select.dart, hand-typed and then
|
||||
/// multiplied by `scaling`, so it never moved with density at all.
|
||||
double get popupMaxHeight => popupRowHeight * popupMaxRows;
|
||||
|
||||
/// Padding for a borderless control (ghost/primary button, etc).
|
||||
EdgeInsets get buttonPadding => EdgeInsets.symmetric(
|
||||
horizontal: buttonPaddingX,
|
||||
vertical: controlPaddingY,
|
||||
);
|
||||
|
||||
/// Padding for a bordered control. A BoxDecoration border adds its width to
|
||||
/// the Container's layout, so the stroke comes out of the padding and the
|
||||
/// outer height stays [controlHeight] either way.
|
||||
EdgeInsets get borderedControlPadding => EdgeInsets.symmetric(
|
||||
horizontal: buttonPaddingX - controlBorderWidth,
|
||||
vertical: controlPaddingY - controlBorderWidth,
|
||||
);
|
||||
|
||||
/// Text fields are bordered controls.
|
||||
EdgeInsets get textFieldPadding => borderedControlPadding;
|
||||
|
||||
// ---- text ----
|
||||
//
|
||||
// The body text ladder. [fontSize] is the control font - what a button
|
||||
// label, a field's text and a select trigger render at - and these are the
|
||||
// sizes for text that ISN'T inside a control: dialog copy, headings, hints.
|
||||
//
|
||||
// Dialog copy reading a step larger than the button labels beneath it is
|
||||
// deliberate - content and controls are different things. The bug was never
|
||||
// that they differed, it's that they were UNLINKED: these were hardcoded
|
||||
// 12/14/18 times `scaling`, while the control font comes off this class and
|
||||
// is deliberately not scaled. So the intended 12-vs-10 held at scaling 1.0
|
||||
// and drifted to 14.4-vs-10 at 1.2. The ratio moved with scaling, which is
|
||||
// the actual defect.
|
||||
//
|
||||
// Deriving them from [fontSize] pins the ratio. At both canonical densities
|
||||
// fontSize is 10, so these come out 12/14/18 - exactly the numbers they
|
||||
// replaced, so nothing shifts at scaling 1.0.
|
||||
|
||||
/// The one step BELOW the control font - a caption sat under something,
|
||||
/// not beside it. A property row's subtitle and description, and the same
|
||||
/// tier anything else that explains a control rather than labelling it
|
||||
/// should reach for.
|
||||
///
|
||||
/// This rung didn't exist. The ladder only ever went UP from [fontSize],
|
||||
/// because in A&A the control font IS the smallest thing on screen - so a
|
||||
/// page needing a caption had nowhere to go and hardcoded one. x0.875 lands
|
||||
/// on 9 against a 10px control font, and 14 against a 16px one.
|
||||
double get textXxs => (fontSize * 0.875).roundToDouble();
|
||||
|
||||
/// Content that sits near controls without being one - dialog copy, page
|
||||
/// headings, hints.
|
||||
///
|
||||
/// x1.1, which lands on the ambient body size: 11 against a 10px control
|
||||
/// font. It was x1.2 (12), matching the literal the shorthands used to
|
||||
/// hardcode - but that put dialog copy and every page heading a fifth above
|
||||
/// the controls beneath them and a pixel above ordinary body text, which
|
||||
/// read as oversized rather than as hierarchy. Weight and colour carry the
|
||||
/// emphasis instead.
|
||||
double get textXs => (fontSize * 1.1).roundToDouble();
|
||||
|
||||
/// Geist's own line box, as a multiple of font size - hhea ascender 1005,
|
||||
/// descender -295, lineGap 0 over a 1000 upem. The package hardcodes Geist
|
||||
/// (see GarageTheme), and a label sets no explicit `height`, so this is the
|
||||
/// ratio its line actually lays out at. Flutter rounds each line to a whole
|
||||
/// pixel, which is why the derivations below round rather than ceil.
|
||||
static const double geistLineRatio = 1.3;
|
||||
|
||||
/// Height of a labelled row's label column - the label on [textXs] with a
|
||||
/// subtitle under it on [textXxs], both on the font's own line box.
|
||||
///
|
||||
/// Worth having as a token because it OUTGROWS [controlHeight] at the small
|
||||
/// tiers: 26 against a 23px compact control. A row sized on controlHeight
|
||||
/// alone therefore got its height from the label rather than from the
|
||||
/// control, which is backwards and moves with the font.
|
||||
double get labelColumnHeight =>
|
||||
(textXs * geistLineRatio).roundToDouble() +
|
||||
(textXxs * geistLineRatio).roundToDouble();
|
||||
|
||||
/// Minimum height of a [PropertyRow]. Clears whichever of the control and
|
||||
/// the label column is taller, plus a step, so neither one is the thing
|
||||
/// setting the row height and a row is the same height with or without a
|
||||
/// subtitle.
|
||||
double get propertyRowHeight =>
|
||||
(labelColumnHeight > controlHeight ? labelColumnHeight : controlHeight) +
|
||||
gapXxs;
|
||||
|
||||
/// Body copy that wants to read a step above the controls.
|
||||
double get textSm => (fontSize * 1.4).roundToDouble();
|
||||
|
||||
/// Headings.
|
||||
double get textLg => (fontSize * 1.8).roundToDouble();
|
||||
|
||||
// ---- layout spacing ----
|
||||
//
|
||||
// The gap scale. Layout spacing BETWEEN widgets - what a call site reaches
|
||||
// for when it puts a `Gap` between two things. Distinct from [controlGap],
|
||||
// which is spacing INSIDE a control, and which is the base unit here.
|
||||
//
|
||||
// Before this existed, call sites picked `Gap(n)` literals by eye. That was
|
||||
// documented rather than derived, and the apps drifted off it - the two
|
||||
// Garage web frontends between them had sixteen distinct gap values,
|
||||
// including a 3, a 5 and ten 14s that no scale would have produced.
|
||||
//
|
||||
// The steps are multiples of [controlGap], which makes [gapMd] equal to
|
||||
// [containerGap] and [gapXl] equal to [containerPadding] at both densities.
|
||||
// That agreement isn't arranged, it's what those two tokens already were -
|
||||
// which is the evidence the base unit is right.
|
||||
//
|
||||
// Rounded because the normal density's base is 5, and a 1.5x step off it
|
||||
// lands on 7.5. A fractional gap isn't wrong the way a fractional line box
|
||||
// is (nothing derives from it), but a whole pixel won't seam on a fractional
|
||||
// device ratio, so it's free to keep them whole.
|
||||
|
||||
/// Hairline separation - a label sat directly above its value.
|
||||
double get gapXxs => (controlGap * 0.5).roundToDouble();
|
||||
|
||||
/// Tight. Icon-adjacent, or items that read as one unit.
|
||||
double get gapXs => controlGap;
|
||||
|
||||
/// Snug, between [gapXs] and the default.
|
||||
double get gapSm => (controlGap * 1.5).roundToDouble();
|
||||
|
||||
/// The default. "These two things are related but distinct." When a call
|
||||
/// site has no particular reason to pick another step, it wants this one.
|
||||
double get gapMd => controlGap * 2;
|
||||
|
||||
/// Section-level: separates groups within a panel or form.
|
||||
double get gapLg => controlGap * 3;
|
||||
|
||||
/// Between major blocks of a layout.
|
||||
double get gapXl => controlGap * 4;
|
||||
|
||||
/// The largest step - page-level separation, above the panel scale.
|
||||
double get gapXxl => controlGap * 6;
|
||||
}
|
||||
|
||||
/// Icon sizes. `small` is the control icon and is derived from the density's
|
||||
/// line box so it can never drift from the text beside it.
|
||||
class IconThemeTokens {
|
||||
const IconThemeTokens({
|
||||
required this.small,
|
||||
required this.medium,
|
||||
required this.large,
|
||||
});
|
||||
|
||||
final IconThemeData small;
|
||||
final IconThemeData medium;
|
||||
final IconThemeData large;
|
||||
|
||||
static IconThemeTokens forDensity(
|
||||
Density density,
|
||||
double scaling,
|
||||
Color color,
|
||||
) => IconThemeTokens(
|
||||
small: IconThemeData(size: density.iconSize, color: color),
|
||||
medium: IconThemeData(size: 20 * scaling, color: color),
|
||||
large: IconThemeData(size: 24 * scaling, color: color),
|
||||
);
|
||||
}
|
||||
|
||||
// The apps first class theme data. Everything the widgets used to pull off
|
||||
// shadcns ThemeData (colours, the global scaling, radius tokens, icon sizes,
|
||||
// the optional surface glass) now lives here as our own thing.
|
||||
class ThemeData {
|
||||
ThemeData({
|
||||
required this.colorScheme,
|
||||
this.scaling = 1.0,
|
||||
this.radius = 0.5,
|
||||
this.surfaceOpacity,
|
||||
this.surfaceBlur,
|
||||
this.enableFeedback,
|
||||
this.panelRadius = 10,
|
||||
this.panelGap = 5,
|
||||
Density density = const Density(),
|
||||
Typography? typography,
|
||||
IconThemeTokens? iconTheme,
|
||||
}) : density = density,
|
||||
// typography and icon sizes are derived from the density so the control
|
||||
// type, the control icon and the control height all move together.
|
||||
typography = typography ?? Typography.forDensity(density),
|
||||
iconTheme =
|
||||
iconTheme ??
|
||||
IconThemeTokens.forDensity(density, scaling, colorScheme.foreground);
|
||||
|
||||
final ColourScheme colorScheme;
|
||||
|
||||
// chrome panel layout - corner radius of the docked panels, and the gap
|
||||
// around + between them in the shell.
|
||||
final double panelRadius;
|
||||
final double panelGap;
|
||||
|
||||
final Typography typography;
|
||||
|
||||
final Density density;
|
||||
|
||||
// haptic/click feedback toggle. null = decide by platform (mobile on).
|
||||
final bool? enableFeedback;
|
||||
|
||||
// used by controls that behave differently on touch platforms.
|
||||
TargetPlatform get platform => defaultTargetPlatform;
|
||||
|
||||
// global ui scale. was shadcns AdaptiveScaling(0.75). widgets multiply their
|
||||
// paddings/sizes by this to stay the size they always were.
|
||||
final double scaling;
|
||||
|
||||
// base radius (rem-ish). the tokens below are derived from it exactly the way
|
||||
// shadcn derived theirs (radius * step).
|
||||
final double radius;
|
||||
|
||||
final IconThemeTokens iconTheme;
|
||||
|
||||
// optional surface glassmorphism - null means opaque / no blur, same defaults
|
||||
// shadcn shipped.
|
||||
final double? surfaceOpacity;
|
||||
final double? surfaceBlur;
|
||||
|
||||
double get radiusXs => radius * 4;
|
||||
double get radiusSm => radius * 8;
|
||||
double get radiusMd => radius * 12;
|
||||
double get radiusLg => radius * 16;
|
||||
double get radiusXl => radius * 20;
|
||||
double get radiusXxl => radius * 24;
|
||||
|
||||
BorderRadius get borderRadiusXs => BorderRadius.circular(radiusXs);
|
||||
BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm);
|
||||
BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd);
|
||||
BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg);
|
||||
BorderRadius get borderRadiusXl => BorderRadius.circular(radiusXl);
|
||||
BorderRadius get borderRadiusXxl => BorderRadius.circular(radiusXxl);
|
||||
|
||||
Radius get radiusMdRadius => Radius.circular(radiusMd);
|
||||
Radius get radiusLgRadius => Radius.circular(radiusLg);
|
||||
Radius get radiusXlRadius => Radius.circular(radiusXl);
|
||||
|
||||
ThemeData copyWith({
|
||||
ColourScheme? colorScheme,
|
||||
double? scaling,
|
||||
double? radius,
|
||||
double? surfaceOpacity,
|
||||
double? surfaceBlur,
|
||||
Typography? typography,
|
||||
Density? density,
|
||||
bool? enableFeedback,
|
||||
double? panelRadius,
|
||||
double? panelGap,
|
||||
IconThemeTokens? iconTheme,
|
||||
}) {
|
||||
return ThemeData(
|
||||
colorScheme: colorScheme ?? this.colorScheme,
|
||||
scaling: scaling ?? this.scaling,
|
||||
radius: radius ?? this.radius,
|
||||
surfaceOpacity: surfaceOpacity ?? this.surfaceOpacity,
|
||||
surfaceBlur: surfaceBlur ?? this.surfaceBlur,
|
||||
typography: typography ?? this.typography,
|
||||
density: density ?? this.density,
|
||||
enableFeedback: enableFeedback ?? this.enableFeedback,
|
||||
panelRadius: panelRadius ?? this.panelRadius,
|
||||
panelGap: panelGap ?? this.panelGap,
|
||||
iconTheme: iconTheme ?? this.iconTheme,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
|
||||
import "package:garage_ui/theme/garage_theme.dart";
|
||||
|
||||
// The apps type scale. shadcn exposed a big Typography object; the GarageUI widgets
|
||||
// only reach for the small set below. `small` is a size style, `medium`/`normal`
|
||||
// are weight styles, and `mono` swaps the font family while inheriting the
|
||||
// ambient size/weight unless a caller overrides them.
|
||||
class Typography {
|
||||
const Typography({
|
||||
this.normal = const TextStyle(fontWeight: FontWeight.w400),
|
||||
this.medium = const TextStyle(fontWeight: FontWeight.w500),
|
||||
this.semiBold = const TextStyle(fontWeight: FontWeight.w600),
|
||||
// `small` is the shared control font used by buttons, fields and selects.
|
||||
// Prefer [Typography.forDensity] over setting this by hand - the size
|
||||
// and line height belong to the density, and the whole control geometry
|
||||
// chain hangs off them.
|
||||
this.small = const TextStyle(
|
||||
fontSize: 10,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
});
|
||||
|
||||
/// Builds the control type from the density, so `small` can never drift from
|
||||
/// the line box the control heights are derived from.
|
||||
factory Typography.forDensity(Density density) => Typography(
|
||||
small: TextStyle(
|
||||
fontSize: density.fontSize,
|
||||
height: density.lineHeight,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
|
||||
final TextStyle normal;
|
||||
final TextStyle medium;
|
||||
|
||||
/// Emphasis above [medium] - section labels, a dialog's title. Was a
|
||||
/// FontWeight.w600 literal in menu.dart, properties.dart and toast.dart.
|
||||
final TextStyle semiBold;
|
||||
final TextStyle small;
|
||||
|
||||
TextStyle sansStyle(TextStyle style) =>
|
||||
GoogleFonts.geist(textStyle: style, fontWeight: style.fontWeight);
|
||||
|
||||
TextStyle get mono => GoogleFonts.geistMono();
|
||||
|
||||
TextStyle monoStyle(TextStyle style) =>
|
||||
GoogleFonts.geistMono(textStyle: style, fontWeight: style.fontWeight);
|
||||
}
|
||||
|
||||
// shadcn hung these little text helpers off every widget (usually a Text). they
|
||||
// merge a style change over whatever DefaultTextStyle is in scope. sizes are
|
||||
// multiplied by the theme scaling so they track the rest of the ui.
|
||||
extension TextStyleExtension on Widget {
|
||||
// sizes come off the density's text ladder, NOT a literal times scaling -
|
||||
// see Density.textXs for why. scaling is left out on purpose: Density isn't
|
||||
// scaled, and these have to stay in step with the control font.
|
||||
Widget xSmall() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(fontSize: t.density.textXs),
|
||||
);
|
||||
Widget small() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(fontSize: t.density.textSm),
|
||||
);
|
||||
Widget large() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(fontSize: t.density.textLg),
|
||||
);
|
||||
|
||||
Widget medium() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => const TextStyle(fontWeight: FontWeight.w500),
|
||||
);
|
||||
Widget semiBold() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => const TextStyle(fontWeight: FontWeight.w600),
|
||||
);
|
||||
Widget bold() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => const TextStyle(fontWeight: FontWeight.w700),
|
||||
);
|
||||
|
||||
Widget muted() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(color: t.colorScheme.mutedForeground),
|
||||
);
|
||||
}
|
||||
|
||||
typedef _StyleFromTheme = TextStyle Function(ThemeData theme);
|
||||
|
||||
class _StyledText extends StatelessWidget {
|
||||
const _StyledText({required this.child, required this.style});
|
||||
|
||||
final Widget child;
|
||||
final _StyleFromTheme style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = GarageTheme.of(context);
|
||||
return DefaultTextStyle.merge(style: style(theme), child: child);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user