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:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+573
View File
@@ -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,
);
}
}