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,503 @@
|
||||
# Garage UI style guide
|
||||
|
||||
This is not the widget API reference (read the source for that — every file in
|
||||
`lib/` is short and commented). This is the *other* half: which widget, which
|
||||
variant, which colour token, in which situation. Getting a Garage app to use
|
||||
`Button` and `Panel` is easy. Getting it to actually look like a Garage app —
|
||||
right variant for the right emphasis, right token for the right surface, right
|
||||
gap for the right kind of space — is the part that doesn't fall out of the
|
||||
API. That's what this document is for.
|
||||
|
||||
It's written from **observed usage** in Arcs & Angles (metro_map_maker) and
|
||||
Music Maker, the two real apps built on this package. Every rule below is
|
||||
backed by a real call site, not a guess at what "should" be idiomatic. Where
|
||||
a pattern only has 1-2 examples, that's said explicitly — treat it as a lead,
|
||||
not a law.
|
||||
|
||||
## Where this comes from
|
||||
|
||||
Two influences, and they are not the same kind of influence — conflating them
|
||||
is the usual misreading:
|
||||
|
||||
- **shadcn (via `shadcn_flutter`) — the API surface.** Variant names
|
||||
(`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`), the
|
||||
`ColourScheme` slot vocabulary, the dot-constructor shape. This is why a
|
||||
shadcn snippet usually *compiles*.
|
||||
- **Blender — the rendering.** Flat chrome, bordered panels, dense controls,
|
||||
no elevation, a properties pane with a hard split down it. This is why the
|
||||
same snippet doesn't *look* like shadcn once it runs.
|
||||
|
||||
So: **not a pixel-for-pixel restyle of shadcn.** The names carried over; the
|
||||
geometry, the density and the entire chrome layer did not. `shadcn_flutter`
|
||||
was dropped as a dependency once the port finished — nothing here defers to
|
||||
it, and where the two disagree on how something should look, this package
|
||||
wins. Expect ported code to compile and then need its spacing and emphasis
|
||||
re-picked against the tables below.
|
||||
|
||||
## Mental model
|
||||
|
||||
A Garage app has three layers, outside-in:
|
||||
|
||||
1. **Chrome** — the app's own furniture: headers, footers, the menu bar, the
|
||||
shell that docks everything else. Flat, dark, no elevation. Reads as part
|
||||
of the window, not as content sitting on the window.
|
||||
2. **Panels** — bordered, rounded, elevated-feeling regions that hold actual
|
||||
tool content (explorer trees, property inspectors, docked windows). This
|
||||
is where most of the UI actually lives.
|
||||
3. **Controls** — buttons, fields, selects, menus. Live inside panels or
|
||||
chrome, never bare against the app background.
|
||||
|
||||
Nothing in this system uses Flutter's Material widgets. There is no
|
||||
`ThemeData.dark()`, no `Colors.black`, no `Scaffold`. Every colour comes from
|
||||
`ColourScheme`, every size comes from `Density`, every font comes from
|
||||
`Typography`. If a widget you're building reaches for `package:flutter/material.dart`
|
||||
or a literal `Color(0x...)` outside `theme/*.dart`, that's the tell you've
|
||||
stepped outside the system.
|
||||
|
||||
## Getting the theme
|
||||
|
||||
```dart
|
||||
final theme = GarageTheme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
```
|
||||
|
||||
Everything hangs off `theme`: `theme.colorScheme`, `theme.density`,
|
||||
`theme.typography`, `theme.iconTheme`, `theme.radiusMd` / `.borderRadiusMd`
|
||||
(and `Sm`/`Lg`/`Xl`/`Xxl`), `theme.panelRadius`, `theme.panelGap`.
|
||||
|
||||
One exception: an app's *unaccented* scheme — the one nobody's accent-colour
|
||||
override has touched — is read through the app's settings provider, not
|
||||
`GarageTheme.of(context)`. In Arcs & Angles that's
|
||||
`context.watch<SettingsProvider>().anaScheme`. Reach for this specifically
|
||||
when you need `chrome` on something that must stay neutral even if the user
|
||||
picked a wild accent colour (this is rare — most code never needs to do this,
|
||||
because `ChromeBar`/`Panel`/`GarageShell` already read `chrome`/`panel`
|
||||
internally). See "Colour tokens" below for why `chrome` gets this treatment.
|
||||
|
||||
## Colour tokens
|
||||
|
||||
`ColourScheme` (in `theme/colour_scheme.dart`) is one flat list of named
|
||||
colours — no light/dark split, no derived roles computed at paint time. Every
|
||||
field is authored by hand per scheme (Arcs & Angles ships eleven: zinc,
|
||||
crimson, slate, forest, stone, teal, indigo, amber, carbon, fuchsia, plus each
|
||||
one's light twin). When you add a new UI surface, you're choosing which of
|
||||
these *existing* tokens it belongs to — you're not inventing a new colour.
|
||||
|
||||
### Base semantic slots (shadcn-shaped, still the backbone)
|
||||
|
||||
| Token | What it's for |
|
||||
|---|---|
|
||||
| `background` / `foreground` | The app's base surface and default text colour. |
|
||||
| `card` / `cardForeground` | `Card`/`SurfaceCard` fill and the text colour merged inside them. |
|
||||
| `popover` / `popoverForeground` / `popoverBorder` | Dropdowns, select popups, context menus — anything that floats over content in an `OverlayPortal`. |
|
||||
| `primary` / `primaryHovered` / `primaryForeground` | The accent. Active/engaged control state, the one CTA in a dialog. Swappable per-user via `withAccent()`. |
|
||||
| `secondary` / `secondaryHovered` / `secondaryForeground` | Neutral filled control — a resting toggle, an attached-to-a-field icon button. Not an accent colour, just "has a fill." |
|
||||
| `muted` / `mutedForeground` | De-emphasised text/backgrounds — labels, hints, disabled-adjacent copy. `.muted()` text extension reads `mutedForeground`. |
|
||||
| `destructive` | Delete/discard/record actions. Used sparingly — 3 call sites total in Arcs & Angles. |
|
||||
| `border` | Generic 1px outline — text field default border colour, general dividing lines. |
|
||||
| `divider` | A line *between* things in a layout (menu separators, section dividers) — conceptually different from `border` even though schemes often set them equal. |
|
||||
| `ring` | Keyboard-focus outline. Not the canvas selection ring — see `canvasSelectionRing`. |
|
||||
| `chart1`-`chart5` | Reserved for data visualisation, unused by the UI kit itself. |
|
||||
|
||||
### App-chrome slots (the part shadcn never had)
|
||||
|
||||
| Token | What it's for |
|
||||
|---|---|
|
||||
| `chrome` | Header/footer/menu-bar background. Deliberately nudged off `background` so it reads as *app furniture*, not content. Read via the unaccented scheme (see above) — chrome shouldn't shift when the user picks an accent. `ChromeBar` and `GarageShell` apply this for you; you'd only reach for it by hand building a header from scratch (see `blender_header.dart`, `scene_stats_hud.dart`). |
|
||||
| `panel` / `panelBorder` / `panelBorderHighlighted` | `Panel`'s fill and border — resting vs. "this is the active/hovered one" (see `Panel(active: ...)`). This is the surface docked tool content lives in. |
|
||||
| `input` / `inputBackground` / `inputBackgroundHovered` / `inputBackgroundFocused` / `inputBorder` | Text field fill (three interaction states) and border. |
|
||||
| `explorerRowEven` / `explorerRowOdd` / `explorerRowHovered` / `explorerRowText` | Zebra-striped tree/list rows (explorer panel, layer lists). |
|
||||
| `menuItemText` / `menuItemHovered` | Dropdown/menu row text and hover fill. |
|
||||
| `popoverItemHovered` | Hover fill for popover rows that aren't menu items (kept distinct from `menuItemHovered` while the two are still being evaluated — may merge later). |
|
||||
| `tooltipBackground` / `tooltipBorder` | Tooltips get their own pair rather than reusing `popover*` — they sit on top of *everything* and want more contrast than a panel-level surface. |
|
||||
| `propertiesSectionBackground` / `propertiesSectionBorder` / `propertiesSectionLabel` | The boxed sub-sections inside an object-properties panel. |
|
||||
| `canvasBackdrop` / `canvasPaper` / `canvasGridMinor` / `canvasGridMajor` / `canvasGridMajorDot` / `canvasBoundary` | Authored canvas colours — not derived at paint time, these are picked by hand per scheme like everything else. App-specific (Arcs & Angles' map canvas); a non-canvas app can mostly ignore this group. |
|
||||
| `canvasAlignmentGuide` | Smart-guide lines while dragging/resizing. Deliberately its own slot, not `ring` — `ring` is keyboard focus, a different job. |
|
||||
| `canvasSelectionRing` | Selection outline/handles. Tracks `primary` for most schemes but exists as its own slot so an achromatic scheme (carbon: near-black/near-white primary) can still give selection actual hue. If you add `withAccent()` support anywhere, remember `canvasSelectionRing` needs the same brightened-for-visibility treatment `_brightenForSelectionRing` gives it, or an accent override leaves the selection ring the one thing on screen still showing the old colour. |
|
||||
|
||||
### Authoring a new scheme
|
||||
|
||||
Look at `settings_state.dart` in the host app, not the package — that's where
|
||||
the actual eleven-scheme palette lives (the package only defines the *type*
|
||||
and the neutral fallback used in tests/demos). Each scheme is grouped under
|
||||
`// ── Accounted for ──` vs `// ── not reviewed yet ──` comments — that's a
|
||||
live audit trail, not decoration; keep using it as new tokens get added so
|
||||
it's visible which colours were deliberately chosen vs. still riding an old
|
||||
default.
|
||||
|
||||
## Density & spacing
|
||||
|
||||
`Density` (`theme/theme_data.dart`) is the **only** place pixel heights get
|
||||
hand-set. Everything else — icon size, padding, line-box height — is a
|
||||
derived getter. The class doc lays out the chain in full; the short version:
|
||||
|
||||
- You set `controlHeight` (23 compact / 27 normal), `fontSize` (10, both
|
||||
densities), `lineHeight` (1.1, both densities).
|
||||
- `lineBox = (fontSize * lineHeight).roundToDouble()` falls out of those.
|
||||
- `iconSize = lineBox` — a control icon always matches the text beside it.
|
||||
- `controlPaddingY = (controlHeight - lineBox) / 2` — padding is *derived
|
||||
from* the height target, never the other way round.
|
||||
|
||||
**Never hand-type a control height or a vertical padding.** If a number needs
|
||||
tuning, it belongs in `Density`, not at the call site — that's exactly the
|
||||
mistake the class doc says produced four different control heights and a
|
||||
stray `Transform.translate` before this system existed.
|
||||
|
||||
Two densities are in active use: `ButtonDensity.compact` (54 call sites) is
|
||||
the default for tool chrome — toolbars, menus, panels. `ButtonDensity.normal`
|
||||
(5 call sites) shows up for things meant to feel less cramped — e.g. the
|
||||
`Button.primary` "confirm this row" pattern in `blender_properties_section.dart`
|
||||
via `ButtonDensity.fromTheme(theme)`. Default to compact unless you have a
|
||||
specific reason not to.
|
||||
|
||||
Two spacing scales, don't mix them up:
|
||||
- `controlGap` — INSIDE a control (icon-to-label gap). Tighter, on purpose.
|
||||
It's also the base unit the gap scale below is derived from.
|
||||
- `containerGap` / `containerPadding` — between controls in a panel/popover/
|
||||
dialog, and the padding inside one.
|
||||
|
||||
### The gap scale
|
||||
|
||||
Layout spacing between widgets comes off `theme.density`, not a literal:
|
||||
|
||||
| Step | compact | normal | Use it for |
|
||||
|---|---|---|---|
|
||||
| `gapXxs` | 2 | 3 | hairline — a label sat directly above its value |
|
||||
| `gapXs` | 4 | 5 | tight — icon-adjacent, or items reading as one unit |
|
||||
| `gapSm` | 6 | 8 | snug |
|
||||
| `gapMd` | 8 | 10 | **the default** — related but distinct |
|
||||
| `gapLg` | 12 | 15 | section-level, within a panel or form |
|
||||
| `gapXl` | 16 | 20 | between major blocks |
|
||||
| `gapXxl` | 24 | 30 | page-level |
|
||||
|
||||
```dart
|
||||
const Gap.md(), // <- this, not Gap(8)
|
||||
```
|
||||
|
||||
`Gap` has a named constructor per step. They resolve their extent from the
|
||||
theme at build time, so a call site stays `const` and still tracks the density
|
||||
- no `GarageTheme.of(context)` needed in a build method just because it
|
||||
contains a gap. `Gap(n)` with a literal still works and still wins, for the
|
||||
rare thing that genuinely isn't on the scale.
|
||||
|
||||
When in doubt, reach for `gapMd`. It's the same 8px `Gap(8)` was, so the old
|
||||
advice hasn't changed — it just has a name now, and it moves when the density
|
||||
does instead of staying 8 forever.
|
||||
|
||||
`gapMd` equals `containerGap` and `gapXl` equals `containerPadding` at both
|
||||
densities. That's not arranged, it's what those two were already set to, which
|
||||
is the evidence `controlGap` is the right base unit —
|
||||
`test/density_gap_scale_test.dart` holds it to that.
|
||||
|
||||
**Reaching for a literal is now the exception, not the rule.** Before the
|
||||
scale existed the two Garage web frontends had drifted to sixteen distinct gap
|
||||
values between them, including a 3, a 5 and ten 14s. If a step doesn't fit,
|
||||
that's worth a conversation about the scale rather than a one-off number.
|
||||
|
||||
## Buttons — variant semantics
|
||||
|
||||
`Button` and `IconButton` both expose five named constructors:
|
||||
`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`. Real usage
|
||||
across both apps settles into a clear pattern — this is the single most
|
||||
useful thing in this document:
|
||||
|
||||
| Variant | Meaning | Evidence |
|
||||
|---|---|---|
|
||||
| **ghost** | Default, lowest-emphasis action. Toolbar icons, dialog close (X) buttons, settings-cog buttons. Most common `IconButton` variant by a wide margin (15 sites). | `pane_dialog.dart` close button, `blender_editor.dart` header icons |
|
||||
| **outline** | Second most common (`Button.outline`: 14 sites). The "resting/inactive" half of a toggle pair, AND the standard "Cancel"/dismissive action in a dialog action row. | `export_dialog_widgets.dart`: `Button.outline(onPressed: onCancel, child: Text("Cancel"))` |
|
||||
| **secondary** | Neutral filled control — NOT a toggle's resting state (that's outline), more like "has a job but isn't the emphasised one." An icon button glued onto a text field (browse/file-picker button in a `ButtonGroup`) — see *Properties* below; the field goes `TextFieldVariant.secondary` to match, and reaching for `outline` here is the usual slip. Also used as a toggle's resting state in a couple of places (Music Maker's transport controls) — outline and secondary are somewhat interchangeable for "not active right now," pick whichever reads better against the surrounding controls. | `export_dialog_widgets.dart` browse button, Music Maker transport |
|
||||
| **primary** | The accent colour. Two jobs: (1) the *engaged* half of a toggle-button pair — `condition ? primary : outline` is the standard toggle idiom, used repeatedly (`_SnapPopoverButton`, `draw_panel.dart` eyedropper, Music Maker play/pause); (2) the single confirm/CTA action in a dialog, almost always via the `PrimaryButton` shorthand rather than `Button.primary` directly. | `_open ? IconButton.primary(...) : IconButton.outline(...)` |
|
||||
| **destructive** | Reserved for genuinely dangerous/irreversible actions — delete, record. Rare on purpose (3 sites total). Don't reach for it just because something is "important." | Music Maker's record toggle: `recording ? IconButton.destructive(...) : IconButton.secondary(...)` |
|
||||
|
||||
**The toggle-button recipe** (this exact shape appears in every editor):
|
||||
|
||||
```dart
|
||||
active
|
||||
? IconButton.primary(
|
||||
density: ButtonDensity.compact,
|
||||
icon: Icon(LucideIcons.some_icon).iconSmall,
|
||||
onPressed: onToggle,
|
||||
)
|
||||
: IconButton.outline(
|
||||
density: ButtonDensity.compact,
|
||||
icon: Icon(LucideIcons.some_icon).iconSmall,
|
||||
onPressed: onToggle,
|
||||
)
|
||||
```
|
||||
|
||||
**The dialog action-row recipe** (`export_dialog_widgets.dart`, verbatim shape):
|
||||
|
||||
```dart
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Button.outline(onPressed: onCancel, child: const Text("Cancel")),
|
||||
const Gap(8),
|
||||
PrimaryButton(onPressed: enabled ? onConfirm : null, child: Text(confirmLabel)),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
There are also bare `PrimaryButton` / `SecondaryButton` / `OutlineButton` /
|
||||
`GhostButton` / `DestructiveButton` widgets (no `.constructor` dot-syntax) —
|
||||
lighter-weight wrappers around the same variants. `PrimaryButton` specifically
|
||||
is the idiomatic way to write a dialog's confirm button, over `Button.primary`.
|
||||
|
||||
## Icons
|
||||
|
||||
- Source: `flutter_lucide`, re-exported through `garage_ui.dart` as
|
||||
`LucideIcons`. **Names are snake_case** (`LucideIcons.file_plus`,
|
||||
`LucideIcons.chevron_right`) — this is `flutter_lucide`'s native spelling,
|
||||
used directly. There is no camelCase shim in this package.
|
||||
- Three tiers: `.iconSmall` (`density.iconSize`), `.iconMedium` (20px),
|
||||
`.iconLarge` (24px). Set size via the extension, never a hardcoded `size:`.
|
||||
- **`Button` and `IconButton` already apply `small` to everything inside
|
||||
them** — `leading`, `trailing` and the child — so a bare `Icon` in a button
|
||||
is correctly sized and needs no extension. Reach for one only to *override*
|
||||
that. Outside a button the ambient default is `medium` (20px), which is
|
||||
usually too big for a control-adjacent icon; that's where a bare `Icon` does
|
||||
go wrong.
|
||||
- `small` is `1.1 × fontSize` — 13px at product, 11px at compact/normal — not
|
||||
the line box. It was the line box, which is tidy for layout (an icon-only
|
||||
control matches a text control's height for free) and wrong for the eye:
|
||||
lucide glyphs fill their box nearly edge to edge while a 12px font caps out
|
||||
around 8.5px, so a line-box icon read ~70% taller than the text next to it.
|
||||
Control height is unaffected either way — that comes from `controlHeight`.
|
||||
- Muted/de-emphasised icon: chain `.muted()` after the size extension —
|
||||
`Icon(LucideIcons.chevron_left).iconSmall.muted()`.
|
||||
|
||||
## Typography
|
||||
|
||||
`theme.typography` exposes `normal` / `medium` (weight-only styles) and
|
||||
`small` (the shared control font — size and line-height come from `Density`,
|
||||
never set this by hand). Two font-family helpers:
|
||||
|
||||
- `theme.typography.sansStyle(style)` — Geist, the UI's default face.
|
||||
- `theme.typography.monoStyle(style)` — Geist Mono. Reach for this
|
||||
specifically for **numeric readouts**: perf stats, transport time,
|
||||
coordinates — anywhere the content is a number that benefits from
|
||||
fixed-width digits. Not for general UI text.
|
||||
|
||||
Text-widget shorthands (`Widget` extensions, merge over ambient
|
||||
`DefaultTextStyle`): `.xSmall()` / `.small()` / `.large()`, `.medium()` /
|
||||
`.semiBold()` / `.bold()`, `.muted()`.
|
||||
|
||||
### The text ladder
|
||||
|
||||
The three size shorthands come off `density`, not literals:
|
||||
|
||||
| Shorthand | Token | compact / normal | Use it for |
|
||||
|---|---|---|---|
|
||||
| `.xSmall()` | `textXs` | 11 | dialog copy, headings, hints — and the ambient body size |
|
||||
| `.small()` | `textSm` | 14 | body copy a step above the controls |
|
||||
| `.large()` | `textLg` | 18 | headings |
|
||||
|
||||
`textXs` sits one step above `density.fontSize` — 11 against a 10px control
|
||||
font — and is also what `GarageTheme` sets as the ambient body size, so page
|
||||
copy and dialog copy are the same size by construction.
|
||||
|
||||
It was `x1.2` (12) for a while, which put every dialog title and page heading a
|
||||
fifth above the controls beneath them. That read as oversized rather than as
|
||||
hierarchy — weight and colour carry the emphasis, not size.
|
||||
|
||||
What was wrong before is that the two were *unlinked*: these were hardcoded
|
||||
`12`/`14`/`18` multiplied by `scaling`, while the control font comes off
|
||||
`Density` and is deliberately not scaled. 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. Deriving
|
||||
them pins it, and makes `density.fontSize` the single knob that moves every
|
||||
piece of app text together.
|
||||
|
||||
`scaling` is deliberately **not** applied to these. It still moves the
|
||||
medium/large icon tiers, border widths and `GarageTheme`'s own
|
||||
`DefaultTextStyle` — but text that has to line up with a control cannot be on
|
||||
a different axis from the control.
|
||||
|
||||
## Structural widgets
|
||||
|
||||
- **`Panel`** — the bordered, rounded surface for docked tool content. Two
|
||||
modes: standalone (tracks its own hover) or controlled (`active: bool`,
|
||||
parent drives it — used when multiple panels need to be mutually exclusive,
|
||||
e.g. only one lit at a time in a Blender-style three-pane layout). Border
|
||||
goes from `panelBorder` to `panelBorderHighlighted` when active/hovered.
|
||||
- **`ChromeBar`** — the shared treatment for a header/footer strip: fixed
|
||||
height, `chrome` background, padding. Both the header and footer in an
|
||||
editor should be built from this rather than a raw `Container`, so they
|
||||
stay pixel-identical in height (`kChromeBarHeight`, shared constant).
|
||||
- **`EditorShell`** — header / center / optional left+right docks / footer,
|
||||
stacked as one frame. The outermost layout of a whole editor screen.
|
||||
- **`GarageShell`** — main content + two stacked sidebar panes with a
|
||||
draggable resize handle between main and sidebar, plus centralised hover
|
||||
(only one of the three panes lit at once). This is the Blender-style
|
||||
three-pane editor shell, factored out so it isn't hand-rolled per app.
|
||||
- **`ButtonGroup`** — lays controls out in a row (or column) and zeroes the
|
||||
corners where they touch, so they read as one connected control rather than
|
||||
two things that happen to be adjacent. A field with a button welded to its
|
||||
end (password + edit, path + browse, input + unit) is a `ButtonGroup`, not a
|
||||
`Row` with a `Gap` in it. Nested groups merge rather than shadow — an inner
|
||||
group can be told to drop both its top and its start edge, which is how a
|
||||
stacked field/eye/button cluster avoids a doubled stroke down the seam.
|
||||
|
||||
Watch `expands`. It defaults to `false`, which wraps the flex in an
|
||||
`IntrinsicHeight`; the group stretches its children on the cross axis, so
|
||||
without that wrapper it needs a bounded height from its parent and throws
|
||||
`BoxConstraints forces an infinite height` when it doesn't get one. Only
|
||||
pass `expands: true` when the parent already gives it a height.
|
||||
|
||||
- **`Card`** vs **`SurfaceCard`** — `Card` always draws its own
|
||||
`OutlinedContainer` (border + fill). `SurfaceCard` additionally understands
|
||||
sheet-overlay context: inside a sheet it collapses to just padding, because
|
||||
the sheet is already the surface and a nested card would double up the
|
||||
border. Default to `Card` unless the content might end up inside a sheet.
|
||||
- **`OutlinedContainer`** — the base primitive `Card`/`Panel` build on:
|
||||
border + radius + optional shadow. Reach for it directly for one-off
|
||||
floating chrome that isn't quite a card — e.g. a mobile slide-in panel:
|
||||
|
||||
```dart
|
||||
OutlinedContainer(
|
||||
borderColor: GarageTheme.of(context).colorScheme.border,
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0xff000000).withValues(alpha: 0.15), blurRadius: 4, spreadRadius: 2),
|
||||
],
|
||||
child: ...,
|
||||
)
|
||||
```
|
||||
|
||||
A bigger, "floating well above the app" shadow (splash/welcome overlay)
|
||||
goes heavier and uses negative spread to keep the blur from reading as a
|
||||
hard edge: `blurRadius: 40, spreadRadius: -8, offset: Offset(0, 20)`, alpha
|
||||
`0.4`. Scale shadow weight to how far off the page the thing is meant to
|
||||
read as floating — a docked panel border shadow and a modal-over-everything
|
||||
shadow should not look like the same intensity.
|
||||
|
||||
## Properties — the settings-row system
|
||||
|
||||
Any screen that is a list of *things you can change* is built from
|
||||
`PropertiesSection` + `PropertyRow`. This covers settings panes, inspectors,
|
||||
and account screens. Do not assemble one out of `Column` + `Text` + a
|
||||
divider — the split alignment, the collapse behaviour, the actions band and
|
||||
the row minimum height are all in here already.
|
||||
|
||||
- **`PropertiesSection`** — a titled, collapsible block: `title`, `subtitle`,
|
||||
`rows`, an optional `actions` band along the bottom for section-level
|
||||
buttons (Save / Reset / Refresh), and `collapsed` + `onToggle` driven by the
|
||||
parent so several sections can be remembered independently.
|
||||
- **`PropertyRow`** — one setting. `label` on the left of the split, `child`
|
||||
(the control) on the right. `split` is the fraction of the width sitting
|
||||
left of that line, so every row in a section lines its controls up at the
|
||||
same x. `labelless: true` for a row that has no name of its own.
|
||||
|
||||
### `subtitle` vs `description` — the one that gets got wrong
|
||||
|
||||
Both are muted second lines. They are not interchangeable, and picking the
|
||||
wrong one is the single most common mistake against this component:
|
||||
|
||||
| | Where it renders | What it's for |
|
||||
|---|---|---|
|
||||
| **`subtitle`** | Inside the **label column**, under the label, holding the same right-alignment against the split | *Naming the value.* "Last used 3d ago", "Never used", "2 permissions · last used 5m ago" — text that says **which** row this is |
|
||||
| **`description`** | **Full width** under the whole row, spanning label *and* control | *Explaining the setting.* Consequences, caveats, what changes when you change it — "Permanently remove this account… This cannot be undone." |
|
||||
|
||||
The test: does the sentence identify **this particular item** (subtitle), or
|
||||
does it explain **what the control does** (description)? A list of five
|
||||
passkeys wants five subtitles, not five full-width paragraphs.
|
||||
|
||||
**Never put explanatory copy in `child`.** It is the single failure mode this
|
||||
component has. A paragraph in the control column shares a cell with the
|
||||
control, so the copy wraps to three lines, the button gets squeezed against
|
||||
the right edge, and the section's split alignment stops meaning anything
|
||||
because every row's control now starts somewhere different. `child` is for
|
||||
the control. Copy goes in `description`.
|
||||
|
||||
```dart
|
||||
// WRONG - copy competing with the control for the same column
|
||||
PropertyRow(
|
||||
label: "Delete account",
|
||||
scheme: scheme,
|
||||
child: Row(children: [
|
||||
Expanded(child: Text("Permanently remove this account…").muted()),
|
||||
const Gap.md(),
|
||||
Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
|
||||
]),
|
||||
)
|
||||
|
||||
// RIGHT - the slot that already exists for it
|
||||
PropertyRow(
|
||||
label: "Delete account",
|
||||
scheme: scheme,
|
||||
description: "Permanently remove this account, all sign-in methods, and "
|
||||
"any OAuth grants. This cannot be undone.",
|
||||
child: Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
|
||||
)
|
||||
```
|
||||
|
||||
A row that is *only* explanation and has no control at all is still a
|
||||
`PropertyRow` — give it the `description` and pass `const SizedBox.shrink()`
|
||||
as the child.
|
||||
|
||||
### Fields inside a property row
|
||||
|
||||
`TextField` takes a `variant`: `TextFieldVariant.outline` (default) or
|
||||
`.secondary`. Inside a properties pane, prefer `.secondary` — it matches the
|
||||
filled treatment the surrounding controls use, and it pairs with
|
||||
`ButtonStyle.secondary()` when a button is welded to the field in a
|
||||
`ButtonGroup`. An `outline` field next to a `secondary` button (or the
|
||||
reverse) reads as two controls from different screens.
|
||||
|
||||
A value the user is not allowed to edit — a password, a verified email — is
|
||||
still a field: `readOnly: true, enabled: false` with a stand-in value, not a
|
||||
bare `Text` floating in the column. It keeps the row's geometry and tells the
|
||||
reader "this is a value that lives here" rather than "this is a caption."
|
||||
|
||||
## The menu system — one model, two renderers
|
||||
|
||||
This is the pattern from the `AppMenuGroup` model in `app_menu.dart`, and
|
||||
it's worth calling out on its own because it's easy to accidentally
|
||||
reinvent per-app (it has been, twice):
|
||||
|
||||
Define the menu once as data — `List<AppMenuGroup>` (`AppMenuGroup` →
|
||||
`AppMenuAction` / `AppMenuCheck` / `AppMenuSeparator`) — not as widgets. Then:
|
||||
|
||||
1. **In-app render**: walk the model into `Menubar`/`MenuButton`/
|
||||
`MenuCheckbox`/`MenuDivider` widgets (see `_toMenuItem`/`_menuItemsFor` in
|
||||
either app's menu code for the translation).
|
||||
2. **Native macOS bar**: `AppMenuNativeRenderer.build(menus, appName: ...)`
|
||||
pushed into an `AppMenuNotifier` sitting above the app's `Router`, via
|
||||
`PlatformMenuHost`.
|
||||
|
||||
Both renderers must consume the **same** computed `menus` value from the
|
||||
**same** build pass — not two independent calls to whatever builds the model.
|
||||
Two separate computations drift: they'll watch slightly different state,
|
||||
recompute at different times, and eventually disagree about what's checked or
|
||||
what a shortcut is (this exact bug shipped and had to be fixed). Compute
|
||||
once, hand the value to both. Gate the native push on
|
||||
`AppMenuNativeRenderer.signature(menus)`, not the list itself — building a
|
||||
fresh `AppMenuGroup` tree (and fresh `SingleActivator`s inside it) on every
|
||||
build is normal and fine, but pushing to the native bar on every build is not
|
||||
— the signature is what turns "recomputed every frame" into "pushed only
|
||||
when it actually changed."
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **No `package:flutter/material.dart`.** Not even for `Colors.black`. Use
|
||||
`Color(0xff000000)` / `Color(0xffffffff)` — literal, not `Colors.*`. The UI
|
||||
kit is deliberately `WidgetsApp`-based, not `MaterialApp`-based, so an app
|
||||
built on it shouldn't reach for Material either.
|
||||
- **Don't hand-type a control height, icon size, or vertical padding.** It
|
||||
belongs in `Density` as a named field with everything else derived from it.
|
||||
- **Don't build `chrome`/`panel` treatment from raw `Container` + hardcoded
|
||||
colour.** Use `ChromeBar`/`Panel`/`EditorShell`/`GarageShell` — they read
|
||||
the right token (and in chrome's case, the right *unaccented* scheme) for
|
||||
you.
|
||||
- **Don't hand-roll a settings row.** `PropertiesSection` + `PropertyRow`
|
||||
exist, and a `Column` of `Text` + control + divider will silently lose the
|
||||
split alignment, the collapse state and the actions band. If you find
|
||||
yourself writing `Row(children: [Expanded(Text(...)), Gap, Button])` inside
|
||||
a settings pane, you want `description:` instead.
|
||||
- **Don't glue a button to a field with a `Gap`.** That's a `ButtonGroup` —
|
||||
it merges the touching corners so the pair reads as one control.
|
||||
- **Don't build a menu as widgets directly.** Model it as `AppMenuGroup` data
|
||||
first (see above), even if there's currently only one renderer consuming
|
||||
it — a native menu bar tends to get added later, and retrofitting a model
|
||||
under existing widget-only menu code is exactly the refactor that
|
||||
motivated this document.
|
||||
- **Don't reach for `destructive` for "this matters."** It means
|
||||
delete/discard/record — genuinely dangerous, genuinely irreversible.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Local development
|
||||
|
||||
How to edit the SDKs and an app at the same time, and see the change in the
|
||||
running app straight away — no push, no tag, no `ref:` bump while you iterate.
|
||||
|
||||
|
||||
## The setup
|
||||
|
||||
Your app keeps depending on the SDKs the normal way, as pinned git deps (see
|
||||
[Install](../README.md#install)):
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
garage_ui:
|
||||
git:
|
||||
url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git
|
||||
path: garage_ui
|
||||
ref: v0.1.0
|
||||
```
|
||||
|
||||
Leave that alone. Clone this repo somewhere, then next to the app's
|
||||
`pubspec.yaml` add a `pubspec_overrides.yaml` that points the packages you're
|
||||
working on at the local checkout:
|
||||
|
||||
```yaml
|
||||
# local garage sdks, so edits hot reload without a push. gitignored, never commit
|
||||
dependency_overrides:
|
||||
garage_ui:
|
||||
path: ../Garage-SDKs/garage_ui
|
||||
```
|
||||
|
||||
The path is relative to the app's folder, so adjust it to wherever your checkout
|
||||
actually lives. If there's a space anywhere in it, quote it:
|
||||
|
||||
```yaml
|
||||
path: "../../Documents/Projects/Garage Services/SDKs/garage_ui"
|
||||
```
|
||||
|
||||
pub reads `pubspec_overrides.yaml` on its own, you dont pass it anything. Run
|
||||
`flutter pub get` and you should see a line per override:
|
||||
|
||||
```
|
||||
! garage_ui 0.1.0 from path ../Garage-SDKs/garage_ui (overridden in ./pubspec_overrides.yaml)
|
||||
```
|
||||
|
||||
If that line isn't there, the override isn't active — check the path.
|
||||
|
||||
|
||||
## Overriding auth, entitlements or iap
|
||||
|
||||
`garage_entitlements` and `garage_iap` both depend on `garage_auth` by path
|
||||
(`path: ../garage_auth` in their pubspecs). So the moment you override either
|
||||
one, the local copy pulls in a local `garage_auth` too — and your app is still
|
||||
asking for the git one. pub sees `garage_auth` from two sources and refuses.
|
||||
|
||||
The rule: **if you override `garage_entitlements` or `garage_iap`, override
|
||||
`garage_auth` as well.**
|
||||
|
||||
```yaml
|
||||
dependency_overrides:
|
||||
garage_auth:
|
||||
path: ../Garage-SDKs/garage_auth
|
||||
garage_entitlements:
|
||||
path: ../Garage-SDKs/garage_entitlements
|
||||
garage_ui:
|
||||
path: ../Garage-SDKs/garage_ui
|
||||
```
|
||||
|
||||
`garage_ui` doesnt depend on any of the others, so it can be overridden on its
|
||||
own.
|
||||
|
||||
|
||||
## Hot reload
|
||||
|
||||
- Changed `pubspec_overrides.yaml` (added, removed, or edited a path)? Run
|
||||
`flutter pub get`, then do a full restart of the app. Hot reload wont pick up
|
||||
a dependency swap.
|
||||
- After that, edits inside the SDK checkout behave like your own app code. Save
|
||||
and hot reload.
|
||||
- Same caveats as app code: if the change is in something that only runs once
|
||||
(`main()`, initial state, a `const` widget tree), hot restart instead.
|
||||
|
||||
|
||||
## Gitignore it
|
||||
|
||||
Add this to the app's `.gitignore`:
|
||||
|
||||
```
|
||||
pubspec_overrides.yaml
|
||||
```
|
||||
|
||||
The path in it only exists on your machine. Commit it and every other checkout
|
||||
breaks, and so does any CI job or Docker image build, because none of them have
|
||||
your local SDK checkout sitting next to the app.
|
||||
|
||||
While the override is active, `pubspec.lock` records the path source instead
|
||||
of the git one. Thats expected. Just dont ship a lockfile in that state — see
|
||||
below.
|
||||
|
||||
Also: never edit the copies under `~/.pub-cache/git`. pub treats that folder as
|
||||
disposable and will overwrite or delete it without asking, and your app isn't
|
||||
necessarily even reading from the copy you changed. Edit a real checkout and
|
||||
override to it.
|
||||
|
||||
|
||||
## Shipping an SDK change
|
||||
|
||||
Once the change works locally:
|
||||
|
||||
1. Commit and push it in this repo.
|
||||
2. Tag a new version, e.g. `v0.1.1`, and push the tag.
|
||||
3. In each app that should get it, bump `ref:` to the new tag. Do it on purpose,
|
||||
per app — dont float `main`.
|
||||
4. Check the app builds **without** the override. Move it aside, resolve against
|
||||
the real tag, and analyze:
|
||||
|
||||
```sh
|
||||
mv pubspec_overrides.yaml pubspec_overrides.yaml.off
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
This is the step that catches a forgotten push, a tag on the wrong commit, or
|
||||
a `ref:` you missed. It also puts `pubspec.lock` back on the git source.
|
||||
5. Deploy, then move the override back if you're carrying on.
|
||||
|
||||
|
||||
## Running the SDKs' own tests
|
||||
|
||||
These have tests:
|
||||
|
||||
```sh
|
||||
cd garage_entitlements && flutter test
|
||||
cd garage_iap && flutter test
|
||||
cd garage_ui && flutter test
|
||||
```
|
||||
|
||||
`garage_auth` has no `test/` dir yet.
|
||||
|
||||
There are two examples, both minimal wiring references rather than full apps:
|
||||
|
||||
- `garage_auth/example/main.dart` — a single file showing `GarageAuth` wired
|
||||
into an app. It has no pubspec of its own.
|
||||
- `garage_iap/example` — a small package wiring `garage_auth` + `garage_iap`
|
||||
together by path. `flutter pub get` and `flutter analyze` in there is a quick
|
||||
way to check the two still fit together.
|
||||
@@ -0,0 +1,345 @@
|
||||
# Offline licences
|
||||
|
||||
How `garage_entitlements` checks a licence key, where it keeps them between
|
||||
runs, and what it takes to do the same thing without the Flutter package.
|
||||
There's a short bit on `garage_iap`'s older licence at the end, for contrast.
|
||||
|
||||
Everything here is read off the source in
|
||||
`garage_entitlements/lib/src/` — `jwks_verify.dart` for the checks,
|
||||
`key_cache.dart` for storage, `garage_entitlements.dart` for `refresh()` and
|
||||
`cached()`. If this doc and the code ever disagree, the code wins.
|
||||
|
||||
- [What a key is](#what-a-key-is)
|
||||
- [The checks](#the-checks)
|
||||
- [Where keys are kept](#where-keys-are-kept)
|
||||
- [refresh()](#refresh)
|
||||
- [cached(), and the subject](#cached-and-the-subject)
|
||||
- [Key rotation](#key-rotation)
|
||||
- [The device clock](#the-device-clock)
|
||||
- [Verifying without the package](#verifying-without-the-package)
|
||||
- [garage_iap's licence, for contrast](#garage_iaps-licence-for-contrast)
|
||||
|
||||
|
||||
## What a key is
|
||||
|
||||
One compact RS256 JWT per entitlement. The header carries `alg` and `kid`, the
|
||||
payload looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user-123",
|
||||
"iss": "https://pay.imbenji.net",
|
||||
"aud": "field-notes/pro",
|
||||
"project": "field-notes",
|
||||
"sku": "pro",
|
||||
"kind": "one_off",
|
||||
"mode": "live",
|
||||
"expires_at": "2026-10-03T00:00:00Z",
|
||||
"iat": 1790000000,
|
||||
"exp": 1790003600
|
||||
}
|
||||
```
|
||||
|
||||
| Claim | What it is |
|
||||
| --- | --- |
|
||||
| `sub` | the user id the key was minted for |
|
||||
| `iss` | who signed it |
|
||||
| `aud` | `"<project>/<sku>"` — one string, not an array |
|
||||
| `project`, `sku` | the same two things again, split out |
|
||||
| `kind` | `"one_off"` or `"subscription"` |
|
||||
| `mode` | `"live"` or `"sandbox"` |
|
||||
| `expires_at` | when the *entitlement* runs out, ISO 8601. Missing for something owned outright |
|
||||
| `iat`, `exp` | seconds since epoch. `exp` is the *key's* expiry |
|
||||
|
||||
`exp` is the access decision. The server clamps it to the entitlement's own
|
||||
expiry before signing, so a key cant outlive the thing it unlocks. `expires_at`
|
||||
is for a UI to show ("renews on the 3rd") — dont gate on it.
|
||||
|
||||
The only claims that get checked are `iss`, `aud`, `sub`, `mode` and `exp`.
|
||||
`project`, `sku` and `kind` are read into the `GarageKey` after the checks
|
||||
pass, and `expires_at` becomes `entitlementExpiresAt`.
|
||||
|
||||
|
||||
## The checks
|
||||
|
||||
`verifyKey()` in `jwks_verify.dart`. The order is fixed, and the first failure
|
||||
stops it — any failure means not entitled. Every failure is an
|
||||
`EntitlementsError` with a `code`, so a caller can treat them all the same.
|
||||
|
||||
Before the six proper checks there are two cheap ones: the token has to split
|
||||
into three parts (`bad_token`) and the header `alg` has to be `RS256`
|
||||
(`bad_alg`). No `none`, no HS256, no negotiating.
|
||||
|
||||
**1. Signature.** Look up the JWKS key whose `kid` matches the header's `kid`,
|
||||
rebuild the RSA public key from its `n` and `e`, and check a PKCS#1 v1.5
|
||||
SHA-256 signature over `header.payload`. That's pointycastle, the same RSA
|
||||
stack the store signs with — there's no JWT library in the package, on
|
||||
purpose. Only `kty: "RSA"` entries are considered.
|
||||
|
||||
A `kid` that isnt in the JWKS is refused (`kid_not_found`), not guessed at. The
|
||||
package wont try the other keys to see if one happens to work. The one
|
||||
exception: if the header has *no* `kid` at all, the first RSA key in the doc is
|
||||
used.
|
||||
|
||||
A bad signature is `bad_signature`. A mangled signature that makes pointycastle
|
||||
throw is logged and treated the same.
|
||||
|
||||
**2. `iss`.** Must equal the issuer the app was built with — the `issuer`
|
||||
constructor argument, which defaults to `kGarageLicenceIssuer`:
|
||||
|
||||
```dart
|
||||
const String kGarageLicenceIssuer = String.fromEnvironment(
|
||||
"GARAGE_LICENCE_ISSUER",
|
||||
defaultValue: "https://pay.imbenji.net",
|
||||
);
|
||||
```
|
||||
|
||||
So `--dart-define=GARAGE_LICENCE_ISSUER=...` overrides it at build time. The
|
||||
point is that it's a constant the app checks against. The token doesnt get to
|
||||
say who it is. Fails as `iss_mismatch`.
|
||||
|
||||
**3. `aud`.** Must be exactly `"<projectSlug>/<sku>"` — a single string. Right
|
||||
project wrong sku fails, right sku wrong project fails, an array fails.
|
||||
`aud_mismatch`.
|
||||
|
||||
**4. `sub`.** Must equal the signed-in user's id. Somebody elses key copied
|
||||
onto this device fails here. `sub_mismatch`. Where the expected `sub` comes
|
||||
from matters a lot offline, see [cached()](#cached-and-the-subject).
|
||||
|
||||
**5. `mode`.** Must equal the `mode` the `GarageEntitlements` was constructed
|
||||
with (`"live"` by default). A sandbox key never satisfies a live check, and the
|
||||
other way round. A missing `mode` fails too. `mode_mismatch`.
|
||||
|
||||
**6. `exp`.** A key with no `exp` is refused (`no_exp`). Otherwise, if the
|
||||
device clock is past it, `expired`.
|
||||
|
||||
If all of that passes you get a `GarageKey`. `has(sku)` then just looks in the
|
||||
verified set — and `key(sku)` re-checks `exp` every time it's asked, dropping a
|
||||
key that went stale while the app was running.
|
||||
|
||||
|
||||
## Where keys are kept
|
||||
|
||||
`KeyCache` is the seam. The default is `SecureKeyCache`, on
|
||||
`flutter_secure_storage` — the same place `garage_auth` keeps its tokens.
|
||||
|
||||
One blob per project, under `ge.keys.<projectSlug>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": { "pro": "<compact jwt>", "extras": "<compact jwt>" },
|
||||
"jwks": { "keys": [ { "kty": "RSA", "kid": "...", "n": "...", "e": "..." } ] }
|
||||
}
|
||||
```
|
||||
|
||||
The JWKS and the whole key set are written together, in one write. There's
|
||||
deliberately no "keys but no JWKS" state, because that's a pile of tokens you
|
||||
cant check.
|
||||
|
||||
What's on disk is the raw tokens, not a verdict. Every load re-verifies them.
|
||||
|
||||
Things worth knowing about the default store:
|
||||
|
||||
- A read that fails (or a blob that wont parse) is logged and treated as no
|
||||
cache.
|
||||
- A write or clear that fails is logged and **not** thrown. So a `refresh()`
|
||||
can succeed in memory and still not have persisted — the next cold start
|
||||
would see the old blob.
|
||||
|
||||
`MemoryKeyCache` is the in memory one, for tests or anywhere you dont want
|
||||
persistence:
|
||||
|
||||
```dart
|
||||
final ent = GarageEntitlements(
|
||||
auth: auth,
|
||||
projectSlug: "field-notes",
|
||||
apiBaseUrl: "https://pay.imbenji.net/api",
|
||||
cache: MemoryKeyCache(),
|
||||
);
|
||||
```
|
||||
|
||||
`clear()` deletes the blob and empties the in memory set. Call it on sign-out.
|
||||
The `sub` check would reject the old user's keys anyway, but there's no reason
|
||||
to leave them on disk.
|
||||
|
||||
|
||||
## refresh()
|
||||
|
||||
```
|
||||
GET <apiBaseUrl>/v1/licences?project=<projectSlug>[&ttl=<seconds>]
|
||||
```
|
||||
|
||||
with the signed-in user's bearer. The response carries the keys and the JWKS
|
||||
together:
|
||||
|
||||
```json
|
||||
{
|
||||
"licences": [ { "sku": "pro", "licence": "<compact jwt>", ... } ],
|
||||
"jwks": { "keys": [ ... ] }
|
||||
}
|
||||
```
|
||||
|
||||
What it does with it, in order:
|
||||
|
||||
1. No `jwks` object in the body is an error (`no_jwks`). Nothing changes.
|
||||
2. Work out the expected `sub` (see below).
|
||||
3. For each entry, take the sku from the token's own `aud` — not the envelope's
|
||||
`sku`, which is a convenience and ignored — and run the full
|
||||
[six checks](#the-checks) against the JWKS that came in the same response.
|
||||
A token with an `aud` it cant split into project/sku is `bad_token`.
|
||||
4. If **any** key fails, the whole refresh throws. The cache and the in memory
|
||||
set are left exactly as they were.
|
||||
5. Only once every key has verified: write the new blob (all keys + the new
|
||||
JWKS), then replace the in memory set and notify listeners.
|
||||
|
||||
Replace, not merge. A sku missing from the response is gone, from memory and
|
||||
from disk. That's how a cancelled subscription stops working the next time the
|
||||
app is online, rather than hanging on untill its key's `exp`. An empty
|
||||
`licences` list empties the set.
|
||||
|
||||
`ttl` asks for shorter keys. The server clamps it, so asking for longer than
|
||||
the project allows doesnt error, it just doesnt get you longer.
|
||||
|
||||
|
||||
## cached(), and the subject
|
||||
|
||||
`cached()` is the boot path: read the blob, verify each key, fill the set. It
|
||||
never fetches keys. But it does need the expected `sub` for check 4, and that
|
||||
is the one place it can need the network.
|
||||
|
||||
The subject comes from `_subject()`:
|
||||
|
||||
- The first time it's asked, it calls `auth.profile()`, which is the OIDC
|
||||
userinfo endpoint (plus the discovery document, if `garage_auth` hasnt
|
||||
fetched that yet this run). It takes `sub` out of the answer.
|
||||
- After that it's remembered on the `GarageEntitlements` instance, so later
|
||||
calls are free.
|
||||
|
||||
So what happens depends on whether that instance has resolved a subject yet:
|
||||
|
||||
- **It has** — say `refresh()` ran earlier this session. `cached()` is fully
|
||||
offline.
|
||||
- **It hasnt, and userinfo is reachable** — one round trip, then offline.
|
||||
- **It hasnt, and userinfo isnt reachable** — a cold start with no network is
|
||||
the usual one. `profile()` throws, `cached()` logs "cant resolve the subject",
|
||||
and **drops every key** from the in memory set. `has()` is false for
|
||||
everything.
|
||||
- **The user isnt signed in** — `profile()` returns null, there's no `sub`
|
||||
(`no_subject`), same result: every key dropped.
|
||||
|
||||
In the last two cases only the in memory set is emptied. The blob on disk is
|
||||
left alone, so a later `cached()` that can resolve the subject picks the keys
|
||||
straight back up.
|
||||
|
||||
Note that it's the first `cached()` on a fresh instance that makes the call, so
|
||||
"verifies whats on disk, zero network" in the README holds once the subject is
|
||||
known, not on a cold offline launch. If your app has to unlock features on a
|
||||
plane from a cold start, that's the path to plan around.
|
||||
|
||||
Once the subject is resolved, each cached key is checked on its own. A key that
|
||||
fails — expired is the normal case, but also rotated away, wrong user, wrong
|
||||
mode — is logged and dropped, and the rest are kept. One dead key doesnt take
|
||||
the set with it. The sku it's checked against is the map key it was stored
|
||||
under, so a token filed under the wrong sku fails `aud`.
|
||||
|
||||
The remembered subject lives as long as the instance and `clear()` doesnt reset
|
||||
it. If a different user can sign in without the app restarting, give them a new
|
||||
`GarageEntitlements` rather than reusing the old one.
|
||||
|
||||
|
||||
## Key rotation
|
||||
|
||||
Rotation needs nothing from the app.
|
||||
|
||||
The JWKS always arrives in the same response as the keys, and is cached with
|
||||
them, so the keys on disk are always paired with the JWKS they were checked
|
||||
against. While both old and new signing keys are in the published JWKS, keys
|
||||
signed by either verify (there's a test for exactly that). When a key is
|
||||
finally rotated out, any cached key still signed by it fails `kid_not_found` on
|
||||
the next `cached()` — and the next `refresh()` brings down fresh keys *and* the
|
||||
new JWKS together, so it sorts itself out the next time the app is online.
|
||||
|
||||
|
||||
## The device clock
|
||||
|
||||
Offline, `exp` is compared against the device clock, and the device clock can
|
||||
be wound back. That's an accepted limitation. The alternative is refusing to
|
||||
work without a network, which is the thing offline keys exist to avoid.
|
||||
|
||||
What bounds it is the key lifetime. The shorter the TTL, the more often the app
|
||||
has to come online and fetch fresh keys anyway.
|
||||
|
||||
|
||||
## Verifying without the package
|
||||
|
||||
If you're checking a key somewhere else — a backend, a CLI, another language —
|
||||
it's the same job.
|
||||
|
||||
**Getting keys and the JWKS.** The package gets both from one call:
|
||||
|
||||
```
|
||||
GET https://pay.imbenji.net/api/v1/licences?project=<projectSlug>
|
||||
Authorization: Bearer <the user's access token>
|
||||
```
|
||||
|
||||
`ttl=<seconds>` is optional. The body is `{"licences": [{"licence": "<jwt>", ...}], "jwks": {...}}`.
|
||||
`garage_entitlements` doesnt call a separate JWKS endpoint — it only ever uses
|
||||
the `jwks` that comes back in that body — so that's the one to use.
|
||||
|
||||
If you're holding a key you got some other way, you still need the JWKS that
|
||||
goes with it. Keep them together, like the package does.
|
||||
|
||||
**Checking one.** In this order, and treat any failure as not entitled:
|
||||
|
||||
0. Three dot-separated parts. Header `alg` is exactly `RS256` — reject anything
|
||||
else before looking further.
|
||||
1. Find the JWKS entry with `kty: "RSA"` and a `kid` equal to the header's
|
||||
`kid`. Not found = reject. Dont fall back to trying every key. Verify
|
||||
RSASSA-PKCS1-v1_5 with SHA-256 over the ASCII bytes of
|
||||
`<header>.<payload>` (the base64url strings as they are in the token), using
|
||||
the public key from base64url `n` and `e`.
|
||||
2. `iss == "https://pay.imbenji.net"`. Hard-code it. Dont read it from the
|
||||
token and trust it.
|
||||
3. `aud == "<project>/<sku>"`, as a plain string, for the project and sku
|
||||
you're gating.
|
||||
4. `sub ==` the user you think you're talking to, from your own session — not
|
||||
from the token.
|
||||
5. `mode ==` `"live"` (or `"sandbox"` while the project is in sandbox). Missing
|
||||
is a fail.
|
||||
6. `exp` present, and now is before it.
|
||||
|
||||
Most JWT libraries will do 1, 2, 3 and 6 for you if you give them the JWKS, pin
|
||||
the algorithm to RS256 and tell them the issuer and audience. `sub` and `mode`
|
||||
you check yourself.
|
||||
|
||||
On a server you have a proper clock and a network, so the offline caveats dont
|
||||
apply — and if all you want is "does this user own it right now", the
|
||||
`/v1/entitlements` ledger is the truth anyway. The key is for when you cant
|
||||
ask.
|
||||
|
||||
|
||||
## garage_iap's licence, for contrast
|
||||
|
||||
`garage_iap` predates all of this and works differently. One licence JWT for
|
||||
the whole app, not one per entitlement:
|
||||
|
||||
- Fetched from `GET <apiBaseUrl>/v1/licence?app=<appSlug>`, with the JWKS
|
||||
fetched separately from `GET <apiBaseUrl>/v1/licence/jwks.json` in the same
|
||||
trip.
|
||||
- Its payload has an `app` claim and a `products` claim — a list of
|
||||
`{sku, kind, expires_at}` for everything the user owns in that app.
|
||||
- Cached as `{token, jwks}` under `gi.licence.<appSlug>` via the
|
||||
`LicenceCache` seam (`SecureLicenceCache`, or `MemoryLicenceCache` for
|
||||
tests). It's only written after it verifies.
|
||||
|
||||
`verifyLicence()` checks less: `RS256`, the signature by `kid` (same lookup
|
||||
rules as above), then `sub`, then `app`, then `exp`. There's no `iss`, no
|
||||
`aud`, and no `mode` check. `has(sku)` then asks whether the sku is anywhere in
|
||||
`products` — the per product `expires_at` isnt looked at, only the licence's
|
||||
own `exp`.
|
||||
|
||||
That's the wallet problem the per-entitlement keys were built to avoid: a
|
||||
lock that only cares about one feature gets handed the list of everything the
|
||||
user owns. It also resolves the subject through `profile()` the same way, so
|
||||
the same cold-offline caveat applies — except there a failure to resolve it
|
||||
throws out of `cachedLicence()` rather than quietly emptying the set.
|
||||
@@ -0,0 +1,473 @@
|
||||
# Platform setup
|
||||
|
||||
The bits of wiring that live in your app's runner folders, entitlements and
|
||||
manifests rather than in Dart. None of it is something a package can do for
|
||||
you, which is why it's all in one place here.
|
||||
|
||||
- [Redirect URIs](#redirect-uris)
|
||||
- [Catching the callback on native](#catching-the-callback-on-native)
|
||||
- [Token storage](#token-storage)
|
||||
- [macOS network access](#macos-network-access)
|
||||
- [garage_iap and Stripe](#garage_iap-and-stripe)
|
||||
- [garage_ui's macOS plugin](#garage_uis-macos-plugin)
|
||||
- [The pub bug with git + path deps](#the-pub-bug-with-git--path-deps)
|
||||
|
||||
|
||||
## Redirect URIs
|
||||
|
||||
### Registering the client
|
||||
|
||||
Your app is a **public** PKCE client. `garage_auth` never sends a
|
||||
`client_secret` — it cant, anything shipped in an app binary isnt a secret.
|
||||
Create the client under your project in the hub (or over the API with
|
||||
`"is_public": true`) and list every redirect URI the app will ever send.
|
||||
|
||||
What Garage lets you register:
|
||||
|
||||
| Shape | Example | For |
|
||||
| --- | --- | --- |
|
||||
| `https://` | `https://fieldnotes.example/auth/callback` | web |
|
||||
| `http://` on loopback only | `http://localhost:8765/auth/callback` | local dev |
|
||||
| custom scheme | `fieldnotes://auth/callback` | native + desktop |
|
||||
|
||||
The `redirect_uri` sent to `/authorize` has to match a registered one
|
||||
character for character. There are no wildcards. The one thing that floats is
|
||||
the **port on a loopback URL** — register `http://localhost:8765/auth/callback`
|
||||
and `flutter run -d chrome` on whatever random port it picks will still match.
|
||||
Scheme, host and path still have to be exact.
|
||||
|
||||
`garage_auth` sends the same resolved URI to `/authorize` and again in the
|
||||
token exchange, so if one works the other will too.
|
||||
|
||||
### Web ignores your scheme and host
|
||||
|
||||
On web, `redirect_web.dart` throws away the scheme and host of the
|
||||
`redirectUri` you passed and uses `window.location.origin` instead. Only the
|
||||
**path** is kept. So with
|
||||
|
||||
```dart
|
||||
GarageAuth(redirectUri: "https://fieldnotes.example/auth/callback", ...)
|
||||
```
|
||||
|
||||
a build served from `https://beta.fieldnotes.example` sends
|
||||
`https://beta.fieldnotes.example/auth/callback`. That's deliberate, the IdP
|
||||
bounces back to the same deployment the user started on. The catch is that
|
||||
**every origin you deploy to needs its own registered redirect URI** — prod,
|
||||
staging, a preview domain, all of them. Localhost is covered by the loopback
|
||||
port rule above.
|
||||
|
||||
How the path gets picked:
|
||||
|
||||
- A schemeless value (`/auth/callback`) or an `http(s)` URL — its path is used.
|
||||
- A custom scheme URI — falls back to `/auth/callback`.
|
||||
|
||||
That fallback exists because of a real gotcha. In `garagepay://auth/callback`
|
||||
the `auth` bit is the **host**, not part of the path, and the path is just
|
||||
`/callback`. Taking the path off it used to produce
|
||||
`https://host/callback`, which nobody had registered, and sign-in died with
|
||||
`redirect_uri not registered`. So a custom-scheme URI on web always means
|
||||
`/auth/callback`, whatever you wrote after the scheme.
|
||||
|
||||
If you want web to land somewhere other than `/auth/callback`, pass a web
|
||||
shaped value when you're on web:
|
||||
|
||||
```dart
|
||||
final auth = GarageAuth(
|
||||
issuer: "https://hub.imbenji.net/auth-api",
|
||||
clientId: "field-notes",
|
||||
redirectUri: kIsWeb ? "/signed-in" : "fieldnotes://auth/callback",
|
||||
);
|
||||
```
|
||||
|
||||
and make sure that path is both a route in your app and registered on the
|
||||
client, per origin.
|
||||
|
||||
### Native uses it verbatim
|
||||
|
||||
On iOS, Android, macOS, Windows and Linux (`redirect_io.dart`) the configured
|
||||
URI is used exactly as given. `signIn()` opens the authorize URL in the
|
||||
**external** browser via `url_launcher` (`LaunchMode.externalApplication`) and
|
||||
returns straight away. Garage redirects the browser to your custom scheme, the
|
||||
OS hands that to your app, and your app has to catch it and call
|
||||
`completeSignIn(uri.queryParameters)`. Nothing in `garage_auth` listens for
|
||||
the link itself.
|
||||
|
||||
|
||||
## Catching the callback on native
|
||||
|
||||
Two jobs: tell the OS your app owns the scheme, and listen for the link in
|
||||
Dart. [`app_links`](https://pub.dev/packages/app_links) does the listening on
|
||||
every platform and its per-platform docs are the reference for the runner
|
||||
changes — the snippets below are from those docs, check them against the
|
||||
version you actually resolve.
|
||||
|
||||
The Dart side:
|
||||
|
||||
```dart
|
||||
final appLinks = AppLinks(); // singleton, make it early so the cold-start link isnt missed
|
||||
|
||||
appLinks.uriLinkStream.listen((uri) async {
|
||||
if (uri.scheme != "fieldnotes") return;
|
||||
try {
|
||||
await auth.completeSignIn(uri.queryParameters);
|
||||
} catch (e, st) {
|
||||
print("sign in callback failed: $e\n$st");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`uriLinkStream` delivers the initial link as well as later ones.
|
||||
|
||||
From Flutter 3.24 Flutter's own deep link handling has to be switched off or it
|
||||
fights `app_links` for the link. That's the `FlutterDeepLinkingEnabled` /
|
||||
`flutter_deeplinking_enabled` lines below.
|
||||
|
||||
### iOS
|
||||
|
||||
`ios/Runner/Info.plist`, inside the top `<dict>`:
|
||||
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>fieldnotes</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>fieldnotes</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<false/>
|
||||
```
|
||||
|
||||
`app_links` 7 on iOS needs Flutter 3.38.1 or newer, and supports both the
|
||||
app-delegate and the newer scene lifecycle.
|
||||
|
||||
### macOS
|
||||
|
||||
Same `CFBundleURLTypes` block, in `macos/Runner/Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>fieldnotes</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>fieldnotes</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
`android/app/src/main/AndroidManifest.xml`, inside the `<activity>` for
|
||||
`.MainActivity`:
|
||||
|
||||
```xml
|
||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="fieldnotes" android:host="auth" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
Note the host. For `fieldnotes://auth/callback` the host is `auth` (same
|
||||
host-vs-path thing as the web gotcha above). You can drop `android:host` and
|
||||
match on the scheme alone, but keeping it cuts down on clashing with another
|
||||
app that picked the same scheme.
|
||||
|
||||
Test it without going through sign-in:
|
||||
|
||||
```sh
|
||||
adb shell am start -a android.intent.action.VIEW \
|
||||
-d "fieldnotes://auth/callback?code=x\&state=y"
|
||||
```
|
||||
|
||||
(`completeSignIn` will throw a state mismatch on that, which is fine, it proves
|
||||
the link arrived.)
|
||||
|
||||
### Windows
|
||||
|
||||
Windows doesnt read a manifest for a plain win32 app, the scheme has to go in
|
||||
the registry, and `app_links` wont do that for you. Two routes, both in the
|
||||
`app_links` Windows doc:
|
||||
|
||||
- **Packaged with [`msix`](https://pub.dev/packages/msix)** — add
|
||||
`protocol_activation: fieldnotes` under `msix_config` and the installer
|
||||
registers (and on uninstall, removes) it. Only works for the packaged app,
|
||||
not while debugging.
|
||||
- **Unpackaged** — write `HKCU\Software\Classes\fieldnotes` yourself, with an
|
||||
empty `URL Protocol` value and `shell\open\command` set to
|
||||
`"<path to exe>" "%1"`. The doc has a `win32_registry` snippet for it.
|
||||
|
||||
You also want the link going to the instance thats allready running (the one
|
||||
waiting on sign-in), not a fresh one. In `windows/runner/main.cpp`:
|
||||
|
||||
```cpp
|
||||
#include "app_links/app_links_plugin_c_api.h"
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||
if (SendAppLinkToInstance()) {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Two halves again. The scheme is registered by whatever installs the app — a
|
||||
`.desktop` entry with `x-scheme-handler/fieldnotes` in its mime types (if you
|
||||
build packages with `flutter_distributor`, that's `supported_mime_type` in
|
||||
`make_config.yaml`). And `linux/my_application.cc` has to become a single
|
||||
instance app that accepts the URL on the command line — the `app_links` Linux
|
||||
doc has the exact patch (present the existing window in `activate`, return
|
||||
`FALSE` from `local_command_line`, and swap `G_APPLICATION_NON_UNIQUE` for
|
||||
`G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN`). Copy it
|
||||
from there rather than from here, it touches three spots in the file.
|
||||
|
||||
|
||||
## Token storage
|
||||
|
||||
The default `SecureTokenStore` is `flutter_secure_storage`. Worth knowing
|
||||
before you debug anything: the store doesnt only hold tokens. `beginSignIn()`
|
||||
writes the PKCE verifier and `state` into it, and `completeSignIn()` reads them
|
||||
back. So a store that cant write, or one that forgets across the round trip,
|
||||
breaks **sign-in**, not just "stay signed in". On web the tab reloads at the
|
||||
callback, so a `MemoryTokenStore` there gives you a `State mismatch on OAuth
|
||||
callback` every time.
|
||||
|
||||
### macOS: unsigned builds cant use the Keychain
|
||||
|
||||
On macOS `flutter_secure_storage` wants the `keychain-access-groups`
|
||||
entitlement, in both `DebugProfile.entitlements` and `Release.entitlements`.
|
||||
A useful value for it involves `$(AppIdentifierPrefix)` — your team ID — which
|
||||
only exists when you sign with a real development certificate.
|
||||
|
||||
Ad-hoc signed (`CODE_SIGN_IDENTITY = "-"`, what you get with no team set), it
|
||||
goes wrong either way:
|
||||
|
||||
- **with** the entitlement, the build fails, there's no team for it to resolve
|
||||
against;
|
||||
- **without** it, every Keychain call returns `-34018`
|
||||
(`errSecMissingEntitlement`). Sign-in fails at the verifier stash and
|
||||
`restore()` finds nothing.
|
||||
|
||||
Fixes, pick one:
|
||||
|
||||
1. Sign the app properly (a team, a development cert) and add the entitlement.
|
||||
2. Pass your own `TokenStore` until you do:
|
||||
|
||||
```dart
|
||||
final auth = GarageAuth(
|
||||
issuer: ...,
|
||||
clientId: ...,
|
||||
redirectUri: ...,
|
||||
tokenStore: MyPrefsTokenStore(), // anything that implements read/write/delete
|
||||
);
|
||||
```
|
||||
|
||||
`flutter_secure_storage`'s own README also documents a third way on 10 and
|
||||
newer: `MacOsOptions(usesDataProtectionKeychain: false)` drops to the legacy
|
||||
Keychain, which doesnt need the entitlement. `SecureTokenStore` takes a
|
||||
`storage:` argument so you could hand it one configured like that. We havent
|
||||
leaned on it ourselves, so treat that as their claim, not ours.
|
||||
|
||||
Their README has one more macOS catch: Keychain Sharing needs a provisioning
|
||||
profile, and on a free Apple developer account Xcode embeds a machine specific
|
||||
one, so the built app only launches on the Mac that built it.
|
||||
|
||||
### Web compiled to wasm
|
||||
|
||||
`flutter build web --wasm` fails if the graph resolves `flutter_secure_storage`
|
||||
**9**. Its web half (`flutter_secure_storage_web` 1.x) is written against
|
||||
`dart:html`, which dart2wasm doesnt have. And a custom `TokenStore` doesnt save
|
||||
you — the generated web plugin registrant imports every web plugin in the
|
||||
dependency graph whether your code touches it or not, so it gets compiled
|
||||
anyway.
|
||||
|
||||
The packages allow `flutter_secure_storage: ">=9.2.2 <12.0.0"`. Make sure you
|
||||
resolve onto **10 or 11**, whose web half (`flutter_secure_storage_web` 2.x)
|
||||
doesnt use `dart:html`. If something else in your app is holding it on 9, pin
|
||||
it:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_secure_storage: ^10.0.0 # or ^11.0.0
|
||||
```
|
||||
|
||||
Knock-on effect on Android: `flutter_secure_storage` 10 raised its minimum to
|
||||
**API 23**. If your `minSdk` is lower, bump it.
|
||||
|
||||
On web the storage is best effort either way — the README calls its WebCrypto
|
||||
backed web implementation experimental.
|
||||
|
||||
|
||||
## macOS network access
|
||||
|
||||
A macOS app from `flutter create` runs in the App Sandbox, and the sandbox
|
||||
blocks outgoing connections untill you add the client entitlement. Every one
|
||||
of these packages talks to Garage over HTTP, so without it the first discovery
|
||||
fetch fails (usually as a `SocketException: Connection failed (Operation not
|
||||
permitted)`).
|
||||
|
||||
Add it to **both** `macos/Runner/DebugProfile.entitlements` and
|
||||
`macos/Runner/Release.entitlements`:
|
||||
|
||||
```xml
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
The debug profile file allready has `network.server` in it by default. That's
|
||||
incoming connections, so the flutter tools can talk to the running app — it
|
||||
does nothing for your requests. Dont remove it, and dont mistake it for the
|
||||
one you need. Check both files, it's easy to have one build working and the
|
||||
other not.
|
||||
|
||||
|
||||
## garage_iap and Stripe
|
||||
|
||||
`PurchaseMode.sheet` uses `flutter_stripe`'s PaymentSheet. `garage_iap` pins
|
||||
`flutter_stripe: ^11.1.0`, so these are the 11.x requirements from its README.
|
||||
|
||||
### Where the sheet is even tried
|
||||
|
||||
`sheet.dart` conditionally imports `sheet_io.dart` when `dart:io` exists and
|
||||
`sheet_stub.dart` otherwise:
|
||||
|
||||
- **iOS, Android** — the real sheet.
|
||||
- **macOS, Windows, Linux** — `sheet_io.dart` checks `Platform.isIOS ||
|
||||
Platform.isAndroid` and returns `unsupported`.
|
||||
- **Web** — the stub, always `unsupported`.
|
||||
|
||||
`unsupported` drops to the browser handoff. So does any **subscription**,
|
||||
because the server answers the payment-intent call with `fallback: "handoff"`.
|
||||
|
||||
### The setup is not optional on iOS / Android
|
||||
|
||||
Heads up, this one catches people out: the handoff fallback only covers
|
||||
platforms with **no** sheet. On iOS and Android the sheet is always attempted,
|
||||
and if the platform setup below is missing, `initPaymentSheet` /
|
||||
`presentPaymentSheet` fail. `sheet_io.dart` only swallows a
|
||||
`FailureCode.Canceled` — anything else is logged and rethrown, and `purchase()`
|
||||
throws. It doesnt fall back. If you cant do the setup, call
|
||||
`purchase(..., mode: PurchaseMode.handoff)` explicitly.
|
||||
|
||||
**iOS** — iOS 13 or above. In `ios/Podfile`:
|
||||
|
||||
```ruby
|
||||
platform :ios, '13.0'
|
||||
```
|
||||
|
||||
and match `IPHONEOS_DEPLOYMENT_TARGET` in the Xcode build settings. If you
|
||||
want card scanning, add an `NSCameraUsageDescription` to `Info.plist`.
|
||||
|
||||
**Android** — the Stripe Android SDK uses AppCompat UI and the support fragment
|
||||
manager for the sheet, so:
|
||||
|
||||
1. `MainActivity.kt` extends `FlutterFragmentActivity`, not `FlutterActivity`:
|
||||
|
||||
```kotlin
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
```
|
||||
|
||||
2. The activity theme is a descendant of `Theme.AppCompat`. In
|
||||
`android/app/src/main/res/values/styles.xml`, `LaunchTheme` becomes
|
||||
`parent="Theme.AppCompat.Light.NoActionBar"` and in `values-night/styles.xml`
|
||||
`parent="Theme.AppCompat.DayNight.NoActionBar"`. Stripe's example app sets
|
||||
`NormalTheme` to `parent="Theme.MaterialComponents"`.
|
||||
|
||||
3. minSdk 21+ (but see the API 23 note above), Kotlin 1.8.0+, Android Gradle
|
||||
plugin 8+.
|
||||
|
||||
4. Their ProGuard `-dontwarn` rules in `proguard-rules.pro`, and for 11.x
|
||||
`android.enableR8.fullMode=false` in `gradle.properties`. Copy both from the
|
||||
README of the exact version you resolved, the rule list has changed between
|
||||
versions.
|
||||
|
||||
None of this hot reloads. Do a full rebuild after.
|
||||
|
||||
The handoff itself only needs `url_launcher`, which works everywhere without
|
||||
setup (on macOS it still needs the network entitlement above, like
|
||||
everything else).
|
||||
|
||||
|
||||
## garage_ui's macOS plugin
|
||||
|
||||
`garage_ui` declares one native plugin, macOS only:
|
||||
|
||||
```yaml
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
macos:
|
||||
pluginClass: GarageUiPlugin
|
||||
```
|
||||
|
||||
`GarageUiPlugin` registers two method channels:
|
||||
|
||||
- **`garage/eyedropper`** — `isAvailable` and `pick`, backed by
|
||||
`NSColorSampler` (the system loupe, whole screen).
|
||||
- **`garage/cursor_lock`** — `lock` / `unlock`, hides and pins the cursor while
|
||||
you scrub a number field, and pushes raw `scrubDelta` calls back to Dart
|
||||
while locked.
|
||||
|
||||
It's picked up by the generated plugin registrant, so theres nothing to add to
|
||||
`MainFlutterWindow.swift`. The podspec targets macOS 10.15.
|
||||
|
||||
Everywhere else there's no native side, and the Dart side copes:
|
||||
|
||||
- **Eyedropper** — `eyedropper.dart` imports the web version when
|
||||
`dart.library.html` exists (JS web builds) and uses the browser `EyeDropper`
|
||||
API, which is Chromium only, feature checked. Everything else uses the native
|
||||
version, which answers `false` for "available" off macOS without touching the
|
||||
channel. Callers then fall back to sampling the app's own frame, which is
|
||||
window only but works everywhere. A wasm build has no `dart.library.html`,
|
||||
so it takes the native version: unavailable unless the browser reports
|
||||
macOS, in which case the channel call fails, gets logged, and it's
|
||||
unavailable anyway.
|
||||
- **Cursor lock** — `CursorLock.isSupported` is `!kIsWeb && macOS`. Off macOS
|
||||
`lock()` / `unlock()` do nothing and no deltas come back, so the scrub widget
|
||||
drives itself off Flutter's normal pointer events.
|
||||
|
||||
If you see a `MissingPluginException` for either channel on macOS, the runner
|
||||
hasnt been rebuilt since `garage_ui` was added — a hot restart doesnt register
|
||||
new plugins, a full `flutter run` / build does.
|
||||
|
||||
|
||||
## The pub bug with git + path deps
|
||||
|
||||
On some Dart versions pub loses the dependencies of a **git package that has
|
||||
a path dependency of its own**. That's exactly our shape: `garage_entitlements`
|
||||
and `garage_iap` are git deps (with `path:`) that themselves depend on
|
||||
`../garage_auth` by path. When it hits, the extra deps those packages declare —
|
||||
`pointycastle` is the one you'll notice — go missing from the resolved graph,
|
||||
and the build dies with an error mentioning `package_graph.json` (something
|
||||
like `dependencies for ... missing. Try running flutter pub get`, which doesnt
|
||||
help).
|
||||
|
||||
We've seen it on Dart 3.12.0; 3.12.2 is fine. Upgrading is the real fix. If you
|
||||
cant, declare the missing dep directly in your app so pub resolves it
|
||||
regardless:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
pointycastle: ^4.0.0
|
||||
```
|
||||
|
||||
Upstream: [dart-lang/pub#2447](https://github.com/dart-lang/pub/issues/2447)
|
||||
(git package depending on a path package) and
|
||||
[dart-lang/pub#4674](https://github.com/dart-lang/pub/issues/4674) (the
|
||||
`package_graph.json` error).
|
||||
Reference in New Issue
Block a user