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
+16
View File
@@ -0,0 +1,16 @@
.DS_Store
.idea/
.vscode/
# dart / flutter
.dart_tool/
.packages
.flutter-plugins
.flutter-plugins-dependencies
build/
**/pubspec.lock
pubspec_overrides.yaml
# plugin ephemeral stuff
**/Flutter/ephemeral/
**/.symlinks/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+383
View File
@@ -0,0 +1,383 @@
# Garage SDKs
Flutter packages for building apps against [Garage](https://hub.imbenji.net) —
sign in, offline licence checks, in-app purchasing, and the design system every
Garage app is drawn with.
| Package | What it does |
| --- | --- |
| [`garage_auth`](garage_auth) | **Sign in with Garage.** OIDC Authorization Code + PKCE against the hub, token storage with refresh, and a shared authed HTTP client. Everything else builds on it. |
| [`garage_entitlements`](garage_entitlements) | **Offline licence keys.** One signed key per entitlement, verified against the store's published JWKS, so `has("pro")` works with no network. |
| [`garage_iap`](garage_iap) | **In-app purchasing** for the app-store storefront — products, an embedded Stripe sheet or browser handoff, and an offline-verifiable licence. The storefront is parked; new apps want `garage_entitlements`. |
| [`garage_ui`](garage_ui) | **The design system.** Compact, desktop-first widgets and theme — shadcn-shaped API, Blender-shaped rendering. No Material. |
None of the packages hold secrets. `garage_auth`, `garage_entitlements` and
`garage_iap` only ever call public endpoints, with the signed-in user's own
bearer, and the licence packages only hold the *public* half of the signing key
— they can check a key, they can never mint one.
Everything here is MIT licensed, see [LICENSE](LICENSE).
- [Install](#install)
- [Hosts](#hosts)
- [garage_auth](#garage_auth)
- [garage_entitlements](#garage_entitlements)
- [garage_iap](#garage_iap)
- [garage_ui](#garage_ui)
- [More docs](#more-docs)
## Install
The packages arent on pub.dev. Git-depend on this repo, pick the package with
`path:`, and pin a tag:
```yaml
dependencies:
garage_auth:
git:
url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git
path: garage_auth
ref: v0.1.0
garage_entitlements:
git:
url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git
path: garage_entitlements
ref: v0.1.0
```
Pin a tag, dont float `main`. `garage_entitlements` decides who gets the paid
features, and "it changed under us" is not a fun thing to debug.
`garage_entitlements` and `garage_iap` layer on `garage_auth`. You get it
transitively, but declare it too — you'll be constructing a `GarageAuth`
yourself anyway, and keep all of them on the same `ref`.
**Working on the SDKs at the same time as an app?** Point the app at a local
checkout with a gitignored `pubspec_overrides.yaml`, so edits hot reload with
no push or ref bump. See [docs/local-development.md](docs/local-development.md).
## Hosts
Two of them, and mixing them up is the usual way to lose an afternoon:
```
https://hub.imbenji.net/auth-api identity — the OIDC issuer, for garage_auth
https://pay.imbenji.net/api commerce — products, entitlements, licence keys
https://pay.imbenji.net the hosted checkout buyers land on
```
## garage_auth
```dart
import "package:garage_auth/garage_auth.dart";
final auth = GarageAuth(
issuer: "https://hub.imbenji.net/auth-api",
clientId: "my-app", // your own public PKCE client
redirectUri: "myapp://auth/callback",
);
await auth.restore(); // pick up an existing session on boot
await auth.signIn(); // kicks off OIDC PKCE
final me = await auth.profile(); // userinfo claims
final res = await auth.client.get( // authed http — bearer added, refresh on 401
Uri.parse("https://pay.imbenji.net/api/v1/entitlements"),
);
await auth.signOut();
auth.isSignedIn; // bool
auth.accessToken; // String? — the opaque bearer
```
`GarageAuth` is a `ChangeNotifier`, so hand it to `provider` or a
`ListenableBuilder` and the UI rebuilds on sign in / out. Share **one** instance
across the app — the other packages take it and reuse its session and client.
Scopes default to `openid profile email`; pass `scopes:` for more.
### Sign-in has two halves
OIDC needs a round trip to the identity provider, so it's split:
1. **`signIn()`** (same as `beginSignIn()`) builds the PKCE challenge, stashes
the verifier + state, and sends the user to the authorize endpoint.
- **Web:** the tab navigates away. The future never really "returns" — the
app reloads at the redirect path.
- **Native / desktop:** the system browser opens and the app keeps running.
2. **`completeSignIn(params)`** finishes the token exchange. Feed it the query
params off the inbound callback URL. A bad `state`, a missing code or an
`error` param throws an `AuthError`.
Wiring the callback on web, with `go_router`:
```dart
GoRoute(
path: "/auth/callback",
builder: (context, state) {
final auth = context.read<GarageAuth>();
auth.completeSignIn(state.uri.queryParameters).then((_) {
if (context.mounted) context.go("/");
});
return const Center(child: CircularProgressIndicator());
},
);
```
On native, catch the inbound deep link (`app_links` or similar) and hand it over:
```dart
final uri = Uri.parse(incomingDeepLink);
await auth.completeSignIn(uri.queryParameters);
```
A custom-scheme redirect like `myapp://auth/callback` has to be registered in
each platform's runner — that's the app's job, a package cant do it for you. See
[docs/platform-setup.md](docs/platform-setup.md).
### Tokens
Tokens are **opaque**, validated server side by introspection. The authed
`client` refreshes once on a `401` and replays the request; if the refresh
fails the session is dropped so your UI can ask the user to sign in again.
Only replayable (non-streamed) requests get the refresh.
Storage defaults to `flutter_secure_storage` (`SecureTokenStore`) — Keychain /
Keystore on mobile, libsecret / DPAPI on desktop, best effort on web. Swap it
through the `TokenStore` seam:
```dart
final auth = GarageAuth(
issuer: ...,
clientId: ...,
redirectUri: ...,
tokenStore: MemoryTokenStore(), // tests, or your own hive/prefs store
);
```
Keys are namespaced by `clientId`, so two instances in one app wont collide.
The default store has two sharp edges — **unsigned macOS builds** and **web
compiled to wasm**. Both are in [docs/platform-setup.md](docs/platform-setup.md).
## garage_entitlements
Give it a signed-in `GarageAuth`, a project slug and the commerce base url. It
fetches a key per entitlement, verifies each one, and answers `has(sku)` with no
network at all.
```dart
import "package:garage_entitlements/garage_entitlements.dart";
final ent = GarageEntitlements(
auth: auth, // a signed-in GarageAuth
projectSlug: "field-notes",
apiBaseUrl: "https://pay.imbenji.net/api",
);
await ent.cached(); // boot: verifies whats on disk
await ent.refresh(); // whenever you have a connection
if (ent.has("pro-annual")) unlockTheThing();
```
It's a `ChangeNotifier`, so a `ListenableBuilder` redraws your gates when the set
moves. Call `clear()` on sign-out.
`cached()` needs the user's `sub` to check keys against, and it gets that from
userinfo — one network call per instance, then it's remembered. On a cold start
with no connection it cant resolve it, and no keys load (the ones on disk are
kept for next time).
### One key per entitlement
Money bought one thing, so the key unlocks that one thing. A single project-wide
licence would be a wallet — hand it to a lock that cares about one feature and
it learns everything the user owns. Each key is a compact RS256 JWT whose `aud`
is `"<project>/<sku>"`, which is what lets a lock verify one knowing only its
own sku and the public key.
### The two clocks
```dart
final key = ent.key("pro-annual")!;
key.expiresAt; // the KEY's exp — this IS the access decision
key.entitlementExpiresAt; // when the subscription runs out, or null
```
The server clamps a key's `exp` to the entitlement's own expiry (and the grace
window on a bouncing renewal) before signing, so a key can never outlive the
thing it unlocks. Checking `exp` alone is correct. `entitlementExpiresAt` is how
a UI says "renews on the 3rd" — never gate on it.
### Refresh replaces, it doesnt merge
The one behaviour worth knowing. `refresh()` verifies **every** key in the
response before writing anything, then **replaces** the whole cached set for the
project. Anything not in the response is gone.
A cancelled subscription simply stops coming back. If refresh merged, its key
would sit there working untill its own `exp`. A response that can't be fully
verified leaves the previous set exactly as it was — there's no half-applied
refresh.
### Key lifetime
The server decides, from the product's `licence_ttl_seconds`, then the
project's, then its own default. You can ask for **less** with
`refresh(ttl: ...)`; asking for more is quietly clamped. Shorter means the app
has to come online more often. Longer means a refunded purchase stays unlocked
that long, because nothing revokes a key before its `exp`.
### Sandbox
A sandbox key never satisfies a live check. While the project is in sandbox,
construct with `mode: "sandbox"`.
### The rest
```dart
await ent.entitlements(); // the ledger, straight from the server
await ent.productBySku("pro"); // name + price, for a purchase screen
await ent.portalUrl(productId, returnUrl: "myapp://pay/done");
```
`entitlements()` is the truth, but it's a network call and says nothing offline
— use it for a purchase screen ("you allready own this"), not a feature gate.
`portalUrl()` mints a one-shot handoff code so the buyer doesnt sign in twice,
and gives you back the checkout `Uri`. Launching it is yours — there's no
`url_launcher` or Stripe dependency in this package, on purpose, so it builds
clean on desktop. The `returnUrl` must be registered on the project or checkout
refuses it.
What it isn't: a purchase SDK, a replacement for the ledger, or a revocation
system. How verification works, check by check, and how the cache is laid out:
[docs/offline-licences.md](docs/offline-licences.md).
## garage_iap
> Built for the app-store storefront, which is parked. It still works and the
> storefront still uses it, but a new app selling through Garage wants
> `garage_entitlements` plus the hosted checkout.
```dart
import "package:garage_iap/garage_iap.dart";
final iap = GarageIap(
auth: auth,
appSlug: "my-app",
apiBaseUrl: "https://store.imbenji.net/api",
);
final products = await iap.products(); // List<GarageProduct>
final ents = await iap.entitlements(); // List<GarageEntitlement>
await iap.purchase(products.first, mode: PurchaseMode.sheet); // or .handoff
final licence = await iap.licence(); // cached, offline-capable
final pro = iap.has("pro");
```
### Purchase modes
- **`PurchaseMode.handoff`** — creates a checkout, opens it in the system
browser, then polls entitlements until the product is granted. The webhook is
the source of truth; the redirect is just a nudge. Works everywhere.
- **`PurchaseMode.sheet`** — the embedded `flutter_stripe` PaymentSheet for
one-time products. Subscriptions, and platforms with no native sheet (web,
linux, desktop), fall back to the handoff automatically.
Either way it polls with a backoff afterwards. A grant that hasnt landed by
`pollTimeout` returns `PurchaseOutcome.pending` — not an error, it may still
arrive.
The sheet needs `flutter_stripe`'s own platform setup (iOS 13+, an Android
`FlutterFragmentActivity`, …). On iOS and Android that setup isnt optional —
without it `purchase()` throws rather than falling back, so use
`PurchaseMode.handoff` if you cant do it. Web and desktop fall back on their own.
### Offline licence
`licence()` fetches one licence JWT covering the app's products **and** the JWKS
in the same trip, and caches both (`gi.licence.<appSlug>`). Offline it checks the
RS256 signature against the cached JWKS by `kid`, `exp` against the device
clock, and that `app` and `sub` match. No cached licence, or past `exp` while
offline, means not entitled. `cachedLicence()` is the no-network boot path;
`licence(forceRefresh: true)` forces a fetch.
Seams: `LicenceCache` (`MemoryLicenceCache` for tests), `merchantName` for the
sheet, `pollTimeout`.
## garage_ui
The widgets and theme every Garage app is built with.
```dart
import "package:garage_ui/garage_ui.dart";
final theme = ThemeData( // garage_ui's ThemeData, not material's
colorScheme: ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xFF18181B),
foreground: const Color(0xFFFAFAFA),
primary: const Color(0xFF3B82F6),
),
density: const Density.compact(), // or .normal() / .product()
);
GarageApp.router(routerConfig: router, theme: theme);
```
Inside, everything reads the theme:
```dart
final theme = GarageTheme.of(context);
final cs = theme.colorScheme; // ColourScheme — every colour
theme.density; // Density — every size
theme.typography;
```
Two influences, and they arent the same kind:
- **shadcn — the API.** Variant names (`.primary` / `.secondary` / `.outline` /
`.ghost` / `.destructive`), the `ColourScheme` slot names, the dot-constructor
shape. That's why a shadcn snippet usually *compiles*.
- **Blender — the rendering.** Flat chrome, bordered panels, dense controls, no
elevation. That's why the same snippet doesnt *look* like shadcn once it runs.
There's no `package:flutter/material.dart` anywhere in it. No `Scaffold`, no
`ThemeData.dark()`, no literal `Color(0x…)` outside the theme files — if your
widget reaches for one, you've stepped outside the system.
The API is in the source (every file in `lib/` is short and commented). Which
widget, which variant, which colour token in which situation is the other half,
and that's [docs/garage-ui-style-guide.md](docs/garage-ui-style-guide.md).
The package also ships a small macOS plugin (`GarageUiPlugin`) for the
eyedropper and cursor lock.
## More docs
- [docs/local-development.md](docs/local-development.md) — editing the SDKs
alongside an app, with hot reload
- [docs/platform-setup.md](docs/platform-setup.md) — redirect URIs, macOS
Keychain, wasm, Stripe
- [docs/offline-licences.md](docs/offline-licences.md) — how a licence key is
verified and cached
- [docs/garage-ui-style-guide.md](docs/garage-ui-style-guide.md) — using
garage_ui so it actually looks like a Garage app
The HTTP API these packages call is documented on its own, so you can drop to it
whenever you need to — the SDKs are a convenience, not a requirement. Selling
in particular is three plain HTTP calls and a redirect; the one part you
shouldnt hand-roll is verifying a licence key.
+503
View File
@@ -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.
+146
View File
@@ -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.
+345
View File
@@ -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.
+473
View File
@@ -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).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
# the SDK prints caught errors to console on purpose for debugging
avoid_print: false
+60
View File
@@ -0,0 +1,60 @@
// Minimal, runnable-shaped example of wiring GarageAuth into a Flutter app.
// Not a full app — just the moving parts. See the README for the callback
// route + deep-link handling.
import "package:flutter/material.dart";
import "package:garage_auth/garage_auth.dart";
final auth = GarageAuth(
issuer: "https://hub.imbenji.net/auth-api",
clientId: "my-app",
redirectUri: "myapp://auth/callback",
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await auth.restore();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ListenableBuilder(
listenable: auth,
builder: (context, _) {
return Scaffold(
appBar: AppBar(title: const Text("garage_auth example")),
body: Center(
child: auth.isSignedIn
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Signed in 🎉"),
TextButton(
onPressed: () async {
final me = await auth.profile();
debugPrint("profile: $me");
},
child: const Text("Print profile"),
),
TextButton(
onPressed: auth.signOut,
child: const Text("Sign out"),
),
],
)
: TextButton(
onPressed: auth.signIn,
child: const Text("Sign in with Garage"),
),
),
);
},
),
);
}
}
+12
View File
@@ -0,0 +1,12 @@
// Sign in with Garage — OIDC PKCE auth core for Garage apps.
//
// The public surface is GarageAuth plus the TokenStore seam and AuthError.
// garage_iap (and any future package) builds on this — share one GarageAuth
// instance so everything reuses the same session + authed client.
library;
export "src/garage_auth.dart" show GarageAuth;
export "src/oidc.dart" show AuthError;
export "src/token_store.dart"
show TokenStore, SecureTokenStore, MemoryTokenStore;
export "src/authed_client.dart" show AuthedClient;
+74
View File
@@ -0,0 +1,74 @@
import "dart:async";
import "package:http/http.dart" as http;
// an http.Client that quietly attaches the current bearer token to every
// request, and if a call comes back 401 it tries to refresh the token *once*
// and replays the same request. callers use it exactly like a normal
// http.Client (.get/.post/.send) — auth is invisible to them.
class AuthedClient extends http.BaseClient {
AuthedClient({
required http.Client inner,
required String? Function() tokenSource,
required Future<bool> Function() refresh,
}) : _inner = inner,
_token = tokenSource,
_refresh = refresh;
final http.Client _inner;
final String? Function() _token;
final Future<bool> Function() _refresh;
// we serialise refreshes so a burst of 401s doesnt fire five refreshes at
// once — the first one wins and the rest await it.
Future<bool>? _inflight;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final first = await _inner.send(_withAuth(request, _token()));
if (first.statusCode != 401) return first;
// a streamed body can only be read once, so if the request carried one we
// cant safely replay it. bail out with the 401 in that case.
if (request is! http.Request) return first;
// drain the failed response so the connection can be reused
await first.stream.drain<void>();
final ok = await _refreshOnce();
if (!ok) return first;
return _inner.send(_clone(request, _token()));
}
Future<bool> _refreshOnce() {
_inflight ??= _refresh().whenComplete(() => _inflight = null);
return _inflight!;
}
http.BaseRequest _withAuth(http.BaseRequest req, String? token) {
if (token != null && token.isNotEmpty) {
req.headers["Authorization"] = "Bearer $token";
}
return req;
}
// rebuild a fresh Request because BaseRequest is single-shot once sent
http.Request _clone(http.Request src, String? token) {
final out = http.Request(src.method, src.url)
..headers.addAll(src.headers)
..followRedirects = src.followRedirects
..maxRedirects = src.maxRedirects
..persistentConnection = src.persistentConnection
..bodyBytes = src.bodyBytes;
if (token != null && token.isNotEmpty) {
out.headers["Authorization"] = "Bearer $token";
}
return out;
}
@override
void close() => _inner.close();
}
+305
View File
@@ -0,0 +1,305 @@
import "dart:async";
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:http/http.dart" as http;
import "authed_client.dart";
import "oidc.dart";
import "platform/redirect.dart";
import "token_store.dart";
// storage keys. namespaced by clientId so two GarageAuth instances in the same
// app (different clients) dont clobber each others tokens.
const _kAccess = "ga.access";
const _kRefresh = "ga.refresh";
const _kVerifier = "ga.pkce_verifier";
const _kState = "ga.oauth_state";
// "Sign in with Garage" — the core auth client every Garage app (and the iap
// package) builds on. OIDC Authorization Code + PKCE against the hub, opaque
// token storage + refresh, and an authed http client that replays the bearer.
//
// final auth = GarageAuth(
// issuer: "https://hub.imbenji.net/auth-api",
// clientId: "my-app",
// redirectUri: "myapp://auth/callback",
// );
// await auth.restore(); // pick up an existing session
// await auth.signIn(); // kick off PKCE
// final me = await auth.profile();
// final r = await auth.client.get(Uri.parse(".../v1/whatever"));
//
// it's a ChangeNotifier so UIs can rebuild on sign in / out.
class GarageAuth extends ChangeNotifier {
GarageAuth({
required this.issuer,
required this.clientId,
required this.redirectUri,
this.scopes = const ["openid", "profile", "email"],
http.Client? httpClient,
TokenStore? tokenStore,
PlatformRedirect redirect = const PlatformRedirect(),
}) : _http = httpClient ?? http.Client(),
_store = tokenStore ?? SecureTokenStore(),
_redirect = redirect {
_discovery = OidcDiscovery(issuer, _http);
client = AuthedClient(
inner: _http,
tokenSource: () => _accessToken,
refresh: _refresh,
);
}
final String issuer;
final String clientId;
final String redirectUri;
final List<String> scopes;
final http.Client _http;
final TokenStore _store;
final PlatformRedirect _redirect;
late final OidcDiscovery _discovery;
// the authed http client — adds the bearer, refreshes on 401. share this with
// garage_iap and any other higher level package so they all reuse one session.
late final AuthedClient client;
String? _accessToken;
String? _refreshToken;
bool _restored = false;
String? get accessToken => _accessToken;
bool get isSignedIn => _accessToken != null;
bool get isRestored => _restored;
String _k(String base) => "$base.$clientId";
// -------------------------------------------------------------------------
// session restore — call once on boot
// -------------------------------------------------------------------------
Future<void> restore() async {
try {
_accessToken = await _store.read(_k(_kAccess));
_refreshToken = await _store.read(_k(_kRefresh));
} catch (e) {
print("garage_auth restore failed: $e");
_accessToken = null;
_refreshToken = null;
}
_restored = true;
notifyListeners();
}
// -------------------------------------------------------------------------
// sign in
// -------------------------------------------------------------------------
// begins the PKCE flow. on web this navigates the tab away to the IdP and
// never returns here — the app reloads at the redirect path and must call
// completeSignIn() with the query params. on native it opens the system
// browser; the host app catches the inbound deep link and likewise calls
// completeSignIn(). this is the lower level half of signIn().
Future<void> beginSignIn() async {
if (clientId.isEmpty) {
throw AuthError("GarageAuth: clientId is empty.");
}
final ep = await _discovery.endpoints();
final verifier = randomUrlSafe(64);
final challenge = s256Challenge(verifier);
final state = randomUrlSafe(24);
// stash so completeSignIn can finish the exchange after the round trip
await _store.write(_k(_kVerifier), verifier);
await _store.write(_k(_kState), state);
final resolved = _redirect.resolveRedirectUri(redirectUri);
final authUri = Uri.parse(ep.authorize).replace(queryParameters: {
"response_type": "code",
"client_id": clientId,
"redirect_uri": resolved,
"scope": scopes.join(" "),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
});
await _redirect.navigateTo(authUri.toString());
}
// convenience over beginSignIn(). on web this triggers the redirect and the
// future never really "completes" (the page is leaving) — your callback route
// drives completeSignIn. on native it's the same begin step; the deep-link
// handler completes it. so think of signIn() as "start the flow".
Future<void> signIn() => beginSignIn();
// finishes the exchange. callers pass the query params off the inbound
// callback url (web: go_router state.uri.queryParameters, native: parse the
// deep link). returns true when a token was obtained.
Future<bool> completeSignIn(Map<String, String> params) async {
final code = params["code"];
final returnedState = params["state"];
final error = params["error"];
if (error != null) {
print("garage_auth callback error: $error");
throw AuthError("Sign in failed: $error");
}
if (code == null || code.isEmpty) {
throw AuthError("No authorization code in callback.");
}
final savedState = await _store.read(_k(_kState));
if (savedState == null || savedState != returnedState) {
throw AuthError("State mismatch on OAuth callback.");
}
final verifier = await _store.read(_k(_kVerifier));
if (verifier == null) {
throw AuthError("Missing PKCE verifier — start the sign in again.");
}
final ep = await _discovery.endpoints();
final resolved = _redirect.resolveRedirectUri(redirectUri);
final resp = await _http.post(
Uri.parse(ep.token),
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": resolved,
"client_id": clientId,
"code_verifier": verifier,
// NO client_secret — public client
},
);
if (resp.statusCode != 200) {
print(
"garage_auth token exchange failed: ${resp.statusCode} ${resp.body}");
throw AuthError("Token exchange failed (${resp.statusCode}).");
}
await _absorbTokens(resp.body);
// single-use bits — clear so a stale verifier cant be replayed
await _store.delete(_k(_kVerifier));
await _store.delete(_k(_kState));
notifyListeners();
return true;
}
// -------------------------------------------------------------------------
// refresh — used internally by the authed client on a 401
// -------------------------------------------------------------------------
Future<bool> _refresh() async {
final rt = _refreshToken;
if (rt == null || rt.isEmpty) return false;
try {
final ep = await _discovery.endpoints();
final resp = await _http.post(
Uri.parse(ep.token),
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: {
"grant_type": "refresh_token",
"refresh_token": rt,
"client_id": clientId,
},
);
if (resp.statusCode != 200) {
print("garage_auth refresh failed: ${resp.statusCode}");
// refresh token's no good anymore — drop the session so the UI can
// prompt a fresh sign in rather than spinning on dead tokens.
await _clearTokens();
notifyListeners();
return false;
}
await _absorbTokens(resp.body);
notifyListeners();
return true;
} catch (e) {
print("garage_auth refresh threw: $e");
return false;
}
}
Future<void> _absorbTokens(String body) async {
final j = jsonDecode(body) as Map<String, dynamic>;
final access = j["access_token"] as String?;
if (access == null || access.isEmpty) {
throw AuthError("No access_token in token response.");
}
_accessToken = access;
await _store.write(_k(_kAccess), access);
// some flows rotate the refresh token, some dont return one on refresh —
// only overwrite when we actually got a new one.
final refresh = j["refresh_token"] as String?;
if (refresh != null && refresh.isNotEmpty) {
_refreshToken = refresh;
await _store.write(_k(_kRefresh), refresh);
}
}
// -------------------------------------------------------------------------
// profile / userinfo
// -------------------------------------------------------------------------
// reads the OIDC userinfo claims (sub, email, email_verified, is_developer,
// is_admin, …). goes through the authed client so it refreshes on 401.
// returns null when signed out.
Future<Map<String, dynamic>?> profile() async {
if (!isSignedIn) return null;
final ep = await _discovery.endpoints();
final endpoint = ep.userinfo;
if (endpoint == null) {
throw AuthError("Issuer has no userinfo_endpoint in discovery.");
}
final resp = await client.get(Uri.parse(endpoint));
if (resp.statusCode != 200) {
throw AuthError("userinfo failed (${resp.statusCode}).");
}
return jsonDecode(resp.body) as Map<String, dynamic>;
}
// -------------------------------------------------------------------------
// sign out
// -------------------------------------------------------------------------
Future<void> signOut() async {
await _clearTokens();
notifyListeners();
}
Future<void> _clearTokens() async {
_accessToken = null;
_refreshToken = null;
try {
await _store.delete(_k(_kAccess));
await _store.delete(_k(_kRefresh));
} catch (e) {
print("garage_auth signOut storage clear failed: $e");
}
}
@override
void dispose() {
client.close();
super.dispose();
}
}
+83
View File
@@ -0,0 +1,83 @@
import "dart:convert";
import "dart:math";
import "package:crypto/crypto.dart";
import "package:http/http.dart" as http;
class AuthError implements Exception {
AuthError(this.message);
final String message;
@override
String toString() => message;
}
// the bits of the discovery doc we actually use. userinfo is optional in the
// spec but the hub serves it; we fall back gracefully if it's missing.
class OidcEndpoints {
OidcEndpoints({
required this.authorize,
required this.token,
this.userinfo,
this.endSession,
});
final String authorize;
final String token;
final String? userinfo;
final String? endSession;
}
// fetches + caches /.well-known/openid-configuration under the issuer.
class OidcDiscovery {
OidcDiscovery(this.issuer, this._client);
final String issuer;
final http.Client _client;
OidcEndpoints? _cached;
Future<OidcEndpoints> endpoints() async {
if (_cached != null) return _cached!;
final url = "$issuer/.well-known/openid-configuration";
final resp = await _client.get(Uri.parse(url));
if (resp.statusCode != 200) {
throw AuthError("OIDC discovery failed (${resp.statusCode}) at $url");
}
final j = jsonDecode(resp.body) as Map<String, dynamic>;
final authorize = j["authorization_endpoint"] as String?;
final token = j["token_endpoint"] as String?;
if (authorize == null || token == null) {
throw AuthError("Discovery document missing endpoints.");
}
_cached = OidcEndpoints(
authorize: authorize,
token: token,
userinfo: j["userinfo_endpoint"] as String?,
endSession: j["end_session_endpoint"] as String?,
);
return _cached!;
}
}
// ----- PKCE -----
const _pkceChars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
String randomUrlSafe(int len) {
final rnd = Random.secure();
final sb = StringBuffer();
for (var i = 0; i < len; i++) {
sb.write(_pkceChars[rnd.nextInt(_pkceChars.length)]);
}
return sb.toString();
}
String s256Challenge(String verifier) {
final digest = sha256.convert(utf8.encode(verifier));
return base64Url.encode(digest.bytes).replaceAll("=", "");
}
@@ -0,0 +1 @@
export "redirect_io.dart" if (dart.library.js_interop) "redirect_web.dart";
@@ -0,0 +1,23 @@
import "package:url_launcher/url_launcher.dart";
// native / desktop: the redirect uri is whatever custom scheme the app
// registered (myapp://auth/callback). it has to be wired into the per-platform
// runner manifests by the embedding app — we cant do that from a package. we
// just open the system browser and the host app catches the inbound deep link
// on resume and feeds the params back to completeSignIn().
class PlatformRedirect {
const PlatformRedirect();
// on native the configured redirectUri is authoritative — there's no "origin".
String resolveRedirectUri(String configured) => configured;
Future<void> navigateTo(String url) async {
// fire it at the system browser. we dont await the result of the launch
// because the round trip happens out of process.
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
// native has no live "current url" — the deep link arrives separately and the
// host app passes its params in. return null so callers know to use those.
Uri? currentUri() => null;
}
@@ -0,0 +1,40 @@
import "package:web/web.dart" as web;
// web: the redirect uri is the origin we're served from plus the callback path,
// and "navigate to authorize" literally drives the browser tab away. the app
// reloads at /auth/callback with ?code=&state= in the query.
class PlatformRedirect {
const PlatformRedirect();
// on web we ignore the app's configured scheme and use the live origin so the
// IdP bounces back to the same deployment. we keep the *path* the app asked
// for though, so it can route the callback wherever it likes.
String resolveRedirectUri(String configured) {
final loc = web.window.location;
// pull the path off whatever the app configured. if it gave us a custom
// scheme (a native deep link) fall back to /auth/callback.
//
// careful with the deep link shape: in "garagepay://auth/callback" the
// "auth" is the HOST and the path is only "/callback", so taking the path
// off one of those produced https://host/callback and auth rejected it as
// an unregistered redirect_uri. only take the path when its actually a
// path — schemeless, or a real http(s) url.
var path = "/auth/callback";
final parsed = Uri.tryParse(configured);
if (parsed != null && parsed.path.isNotEmpty) {
final isWebUrl = parsed.scheme == "http" || parsed.scheme == "https";
if (!parsed.hasScheme || isWebUrl) {
path = parsed.path;
}
}
return "${loc.origin}$path";
}
Future<void> navigateTo(String url) async {
web.window.location.assign(url);
}
Uri? currentUri() => Uri.parse(web.window.location.href);
}
+42
View File
@@ -0,0 +1,42 @@
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// small key/value seam so the token storage backend is swappable. the default
// is flutter_secure_storage which covers mobile + desktop properly and falls
// back to a best-effort impl on web. anyone embedding the SDK can hand us their
// own (e.g. an in-memory one for tests, or shared_prefs if they dont care).
abstract class TokenStore {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
}
class SecureTokenStore implements TokenStore {
SecureTokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
@override
Future<String?> read(String key) => _storage.read(key: key);
@override
Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
@override
Future<void> delete(String key) => _storage.delete(key: key);
}
// handy for tests, or platforms where you explicitly dont want persistence.
class MemoryTokenStore implements TokenStore {
final Map<String, String> _m = {};
@override
Future<String?> read(String key) async => _m[key];
@override
Future<void> write(String key, String value) async => _m[key] = value;
@override
Future<void> delete(String key) async => _m.remove(key);
}
+28
View File
@@ -0,0 +1,28 @@
name: garage_auth
description: "Sign in with Garage — OIDC PKCE auth core, token storage + refresh, and a shared authed HTTP client for Garage apps."
version: 0.1.0
publish_to: 'none'
environment:
sdk: ^3.5.0
flutter: ">=3.5.0"
dependencies:
flutter:
sdk: flutter
http: ^1.5.0
crypto: ^3.0.6
flutter_secure_storage: ">=9.2.2 <12.0.0"
url_launcher: ^6.3.1
# only pulled in on web builds for reading the callback url / navigating the tab
web: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,7 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
# caught errors get printed on purpose — a swallowed exception is worse
# than a noisy console.
avoid_print: false
@@ -0,0 +1,21 @@
// Offline licence keys for Garage apps — layered on garage_auth.
//
// One key per entitlement, verified against the store's published JWKS. The
// key is the gate; the ledger is the truth behind it.
//
// final ent = GarageEntitlements(
// auth: auth,
// projectSlug: "field-notes",
// apiBaseUrl: "https://pay.imbenji.net/api",
// );
// await ent.cached(); // boot, no network
// await ent.refresh(); // when theres a connection
// if (ent.has("pro-annual")) unlock();
library;
export "src/garage_entitlements.dart"
show GarageEntitlements, kGaragePortalBaseUrl, kGarageLicenceIssuer;
export "src/models.dart"
show GarageKey, GarageEntitlement, GarageProduct, EntitlementsError;
export "src/key_cache.dart"
show KeyCache, SecureKeyCache, MemoryKeyCache, CachedKeys;
@@ -0,0 +1,391 @@
import "dart:async";
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:garage_auth/garage_auth.dart";
import "package:http/http.dart" as http;
import "jwks_verify.dart";
import "key_cache.dart";
import "models.dart";
/// Where the payment portal lives. Overridable per call, but this is the one
/// buyers actually land on.
const String kGaragePortalBaseUrl = String.fromEnvironment(
"GARAGE_PORTAL_BASE_URL",
defaultValue: "https://pay.imbenji.net",
);
/// What `iss` has to say. A constant, checked against — not something the
/// token gets to tell us.
const String kGarageLicenceIssuer = String.fromEnvironment(
"GARAGE_LICENCE_ISSUER",
defaultValue: "https://pay.imbenji.net",
);
/// Offline licence keys for one project.
///
/// One key per entitlement. Money bought one thing, so a key unlocks that one
/// thing — a project-wide licence would be a wallet, and handing a wallet to a
/// lock that only cares about one feature tells it everything the user owns.
///
/// final ent = GarageEntitlements(
/// auth: auth,
/// projectSlug: "field-notes",
/// apiBaseUrl: "https://pay.imbenji.net/api",
/// );
/// await ent.cached(); // fast, no network
/// await ent.refresh(); // when you have a connection
/// if (ent.has("pro-annual")) { ... }
///
/// A ChangeNotifier, so a ListenableBuilder redraws the gates when the set
/// moves.
class GarageEntitlements extends ChangeNotifier {
GarageEntitlements({
required this.auth,
required this.projectSlug,
required this.apiBaseUrl,
KeyCache? cache,
this.mode = "live",
this.issuer = kGarageLicenceIssuer,
}) : _cache = cache ?? SecureKeyCache();
final GarageAuth auth;
final String projectSlug;
final String apiBaseUrl;
/// Which side of the ledger you expect. A sandbox key must never satisfy a
/// live check, so this is checked rather than believed — set it to "sandbox"
/// while the project is, and back when it goes live.
final String mode;
/// The `iss` a key has to carry.
final String issuer;
final KeyCache _cache;
/// sku -> verified key. The gate reads this.
final Map<String, GarageKey> _keys = {};
/// Every verified key we currently hold, sku -> key.
Map<String, GarageKey> get keys => Map.unmodifiable(_keys);
Uri _u(String path, [Map<String, String>? q]) {
final base = apiBaseUrl.endsWith("/")
? apiBaseUrl.substring(0, apiBaseUrl.length - 1)
: apiBaseUrl;
return Uri.parse("$base$path").replace(queryParameters: q);
}
// -------------------------------------------------------------------------
// the gate
// -------------------------------------------------------------------------
/// The verified key for [sku], or null. This is the gate — it is synchronous
/// and it never touches the network. Call [cached] once at boot and [refresh]
/// when you have a connection.
GarageKey? key(String sku) {
final k = _keys[sku];
if (k == null) return null;
// held keys are checked at load, but a long running app can sit past one.
if (k.isExpired) {
_keys.remove(sku);
return null;
}
return k;
}
/// Do they own [sku] right now.
bool has(String sku) => key(sku) != null;
// -------------------------------------------------------------------------
// fetching
// -------------------------------------------------------------------------
/// Pull the whole key set for this project and REPLACE what we hold.
///
/// Replace, not merge. A cancelled subscription simply stops coming back in
/// the response — merging would leave its key sat there working untill its
/// own exp, which is exactly the bug this shape exists to avoid.
///
/// Every key is verified BEFORE anything is written. A response we cant fully
/// check leaves the previous set alone rather than half-applying it.
///
/// [ttl] asks for a shorter key than the ceiling would give. Asking for more
/// is not an error, it is just quietly clamped.
Future<Map<String, GarageKey>> refresh({Duration? ttl}) async {
_requireSignedIn();
final query = <String, String>{"project": projectSlug};
if (ttl != null) query["ttl"] = "${ttl.inSeconds}";
final resp = await auth.client.get(_u("/v1/licences", query));
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not fetch keys.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final jwks = body["jwks"];
if (jwks is! Map) {
throw EntitlementsError(
"No jwks in the response — the keys cant be checked.",
code: "no_jwks",
);
}
final jwksDoc = Map<String, dynamic>.from(jwks);
final sub = await _subject();
// verify the lot first. the envelope's sku is a convenience; the verified
// token is the only thing we key off.
final verified = <String, GarageKey>{};
final tokens = <String, String>{};
for (final raw in (body["licences"] as List? ?? const [])) {
if (raw is! Map) continue;
final token = raw["licence"] as String?;
if (token == null || token.isEmpty) continue;
final aud = peekAudience(token);
if (aud == null) {
throw EntitlementsError(
"A key came back with no usable audience.",
code: "bad_token",
);
}
final key = verifyKey(
token,
jwksDoc,
expectedIssuer: issuer,
expectedProject: projectSlug,
expectedSku: aud.sku,
expectedSub: sub,
expectedMode: mode,
);
verified[key.sku] = key;
tokens[key.sku] = token;
}
// only now does anything change.
await _cache.write(projectSlug, CachedKeys(keys: tokens, jwks: jwksDoc));
_keys
..clear()
..addAll(verified);
notifyListeners();
return keys;
}
/// Load and verify the cached set. Zero network — this is the boot path, and
/// the one that keeps working on a plane.
///
/// A key that no longer verifies (expired, rotated away, signed for somebody
/// else) is dropped and logged rather than throwing, so one dead key doesnt
/// take the whole set with it.
Future<Map<String, GarageKey>> cached() async {
final blob = await _cache.read(projectSlug);
if (blob == null) {
_keys.clear();
notifyListeners();
return keys;
}
final String sub;
try {
sub = await _subject();
} catch (error, stack) {
// no profile to match against — offline and never signed in properly.
print("[garage_entitlements] cant resolve the subject: $error");
print(stack);
_keys.clear();
notifyListeners();
return keys;
}
final loaded = <String, GarageKey>{};
blob.keys.forEach((sku, token) {
try {
loaded[sku] = verifyKey(
token,
blob.jwks,
expectedIssuer: issuer,
expectedProject: projectSlug,
expectedSku: sku,
expectedSub: sub,
expectedMode: mode,
);
} catch (error) {
// expected often enough (an expired key is the normal case) that a
// stack would be noise — but never silent.
print("[garage_entitlements] dropping cached key '$sku': $error");
}
});
_keys
..clear()
..addAll(loaded);
notifyListeners();
return keys;
}
/// The ledger, straight from the server. The truth, and a fallback for when
/// key signing is switched off on a deployment.
///
/// Gates should ask [key] / [has] instead — this is a network call, it says
/// nothing offline, and it hands back the whole account's grants for the
/// project rather than the one thing you were asking about. Good for a
/// purchase screen ("you allready own this"), wrong for a feature check.
Future<List<GarageEntitlement>> entitlements() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/entitlements"));
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not load entitlements.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (body["entitlements"] as List? ?? const [])
.whereType<Map>()
.map((m) => GarageEntitlement.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
/// One product by sku, off the public portal lookup. Null when there isnt
/// one. No bearer needed, though we send one if we have it so `owned` comes
/// back meaning something.
Future<GarageProduct?> productBySku(String sku) async {
final uri = _u("/v1/portal/by-sku/$projectSlug/$sku");
final resp = auth.isSignedIn
? await auth.client.get(uri)
: await http.get(uri);
if (resp.statusCode == 404) return null;
if (resp.statusCode != 200) {
throw _errorFrom(resp, fallback: "Could not load that product.");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final product = body["product"];
if (product is! Map) return null;
return GarageProduct.fromJson(Map<String, dynamic>.from(product));
}
// -------------------------------------------------------------------------
// buying
// -------------------------------------------------------------------------
/// The portal url for [productId], with a one-shot handoff code on it so the
/// buyer doesnt sign in twice.
///
/// Returns a Uri — LAUNCHING it is yours. This package has no url_launcher
/// dependency and isnt going to grow one; you allready have a way to open a
/// url and it isnt this package's business what it is.
///
/// [returnUrl] has to be registered on the project or checkout refuses it.
Future<Uri> portalUrl(
String productId, {
String? returnUrl,
String? portalBaseUrl,
}) async {
_requireSignedIn();
final base = portalBaseUrl ?? kGaragePortalBaseUrl;
final trimmed = base.endsWith("/")
? base.substring(0, base.length - 1)
: base;
final query = <String, String>{};
if (returnUrl != null && returnUrl.isNotEmpty) {
query["return_url"] = returnUrl;
}
final code = await _mintHandoffCode();
if (code != null) query["handoff"] = code;
return Uri.parse(
"$trimmed/pay/$productId",
).replace(queryParameters: query.isEmpty ? null : query);
}
/// One-shot code from auth, or null when we couldnt get one — the portal
/// still works then, the buyer just signs in when they land.
Future<String?> _mintHandoffCode() async {
try {
final resp = await http.post(
Uri.parse("${auth.issuer}/auth/handoff/issue"),
headers: {"Authorization": "Bearer ${auth.accessToken}"},
);
if (resp.statusCode != 200) {
print(
"[garage_entitlements] handoff issue refused "
"(${resp.statusCode}): ${resp.body}",
);
return null;
}
final json = jsonDecode(resp.body) as Map<String, dynamic>;
return json["handoff_code"] as String?;
} catch (error, stack) {
print("[garage_entitlements] couldnt mint a handoff code: $error");
print(stack);
return null;
}
}
// -------------------------------------------------------------------------
/// Throw the cached set away. Sign-out should call this — the keys are for
/// whoever was signed in, and the sub check would reject them anyway, but
/// leaving them on disk is untidy.
Future<void> clear() async {
await _cache.clear(projectSlug);
_keys.clear();
notifyListeners();
}
// resolve the user id a key has to be for. profile() round trips userinfo,
// which is fine — its behind the authed client and only wanted at load time.
String? _cachedSub;
Future<String> _subject() async {
if (_cachedSub != null) return _cachedSub!;
final me = await auth.profile();
final sub = me?["sub"] as String?;
if (sub == null || sub.isEmpty) {
throw EntitlementsError(
"No subject in the profile — cant match a key to a user.",
code: "no_subject",
);
}
_cachedSub = sub;
return sub;
}
void _requireSignedIn() {
if (!auth.isSignedIn) {
throw EntitlementsError(
"Not signed in — call auth.signIn() first.",
code: "not_signed_in",
);
}
}
EntitlementsError _errorFrom(http.Response resp, {required String fallback}) {
try {
final b = jsonDecode(resp.body) as Map<String, dynamic>;
final code = b["error"] as String?;
final message = b["message"] as String? ?? fallback;
return EntitlementsError(message, code: code);
} catch (error) {
// a non-json body (a gateway page, usually) — still worth saying.
print(
"[garage_entitlements] ${resp.statusCode} with an unreadable body: "
"$error",
);
return EntitlementsError("$fallback (${resp.statusCode})");
}
}
}
@@ -0,0 +1,211 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
import "models.dart";
// Offline key verification. The store signs a key RS256 and publishes the
// matching public half as a JWKS; we only ever hold that half, so we can check
// a key but never mint one.
//
// No jwt library here on purpose. A JWKS RSA verify is "split the compact
// token, rebuild the public key from n/e, check PKCS1v15 SHA-256 over
// header.payload", and pointycastle — the same stack the backend signs with —
// does exactly that.
/// Verify [token] against [jwks] and the caller's expectations.
///
/// The order is fixed and it matters: signature, then `iss`, then `aud`, then
/// `sub`, then `mode`, then `exp`. Anything failing means not entitled — every
/// failure is an [EntitlementsError] so a caller can treat them alike.
///
/// `aud` is `"<project>/<sku>"`, which is what lets a lock check a key knowing
/// only its own sku and the public key.
GarageKey verifyKey(
String token,
Map<String, dynamic> jwks, {
required String expectedIssuer,
required String expectedProject,
required String expectedSku,
required String expectedSub,
required String expectedMode,
}) {
final parts = token.split(".");
if (parts.length != 3) {
throw EntitlementsError("Malformed key token.", code: "bad_token");
}
final header = _decodeJsonSegment(parts[0]);
final payload = _decodeJsonSegment(parts[1]);
final alg = header["alg"] as String?;
if (alg != "RS256") {
throw EntitlementsError("Unexpected key alg: $alg", code: "bad_alg");
}
// 1. signature.
final kid = header["kid"] as String?;
final key = _findKey(jwks, kid);
if (key == null) {
// rotation: the kid isnt in the jwks we hold. a fresh refresh pulls the new
// jwks down with the keys, so this sorts itself out next time we're online.
throw EntitlementsError("No JWKS key for kid '$kid'.",
code: "kid_not_found");
}
final signingInput = utf8.encode("${parts[0]}.${parts[1]}");
final signature = _b64UrlBytes(parts[2]);
if (!_verifyRs256(key, Uint8List.fromList(signingInput), signature)) {
throw EntitlementsError("Key signature failed.", code: "bad_signature");
}
// 2. iss.
final iss = payload["iss"] as String?;
if (iss == null || iss != expectedIssuer) {
throw EntitlementsError("Key issuer mismatch.", code: "iss_mismatch");
}
// 3. aud — one string, "<project>/<sku>".
final aud = payload["aud"];
final wanted = "$expectedProject/$expectedSku";
if (aud is! String || aud != wanted) {
throw EntitlementsError("Key audience mismatch.", code: "aud_mismatch");
}
// 4. sub.
final sub = payload["sub"] as String?;
if (sub == null || sub != expectedSub) {
throw EntitlementsError("Key subject mismatch.", code: "sub_mismatch");
}
// 5. mode. a sandbox key must never satisfy a live check.
final mode = payload["mode"] as String?;
if (mode == null || mode != expectedMode) {
throw EntitlementsError("Key mode mismatch.", code: "mode_mismatch");
}
final iat = _epoch(payload["iat"]);
final exp = _epoch(payload["exp"]);
if (exp == null) {
throw EntitlementsError("Key has no exp.", code: "no_exp");
}
final rawEntExpiry = payload["expires_at"] as String?;
final verified = GarageKey(
subject: sub,
issuer: iss,
project: payload["project"] as String? ?? expectedProject,
sku: payload["sku"] as String? ?? expectedSku,
kind: payload["kind"] as String? ?? "one_off",
mode: mode,
issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
expiresAt: exp,
entitlementExpiresAt: (rawEntExpiry == null || rawEntExpiry.isEmpty)
? null
: DateTime.tryParse(rawEntExpiry)?.toUtc(),
);
// 6. exp. trusts the device clock when offline, which is an accepted
// limitation — the alternative is refusing to work on a plane.
if (verified.isExpired) {
throw EntitlementsError("Key expired.", code: "expired");
}
return verified;
}
/// The `aud` of a compact token without verifying anything — used to work out
/// which sku a cached key belongs to before we know what to check it against.
/// Never trust what comes out of here; it is a routing hint, nothing more.
({String project, String sku})? peekAudience(String token) {
try {
final parts = token.split(".");
if (parts.length != 3) return null;
final payload = _decodeJsonSegment(parts[1]);
final aud = payload["aud"];
if (aud is! String) return null;
final slash = aud.indexOf("/");
if (slash <= 0 || slash == aud.length - 1) return null;
return (project: aud.substring(0, slash), sku: aud.substring(slash + 1));
} catch (error, stack) {
print("[garage_entitlements] couldnt peek at a token's aud: $error");
print(stack);
return null;
}
}
// ---- key lookup ----
// pull the RSA public key for a kid out of the jwks. a header with no kid and
// exactly one key in the doc is fine — take that one.
RSAPublicKey? _findKey(Map<String, dynamic> jwks, String? kid) {
final keys = jwks["keys"];
if (keys is! List || keys.isEmpty) return null;
Map<String, dynamic>? match;
for (final k in keys) {
if (k is! Map) continue;
final m = Map<String, dynamic>.from(k);
if (m["kty"] != "RSA") continue;
if (kid == null || m["kid"] == kid) {
match = m;
break;
}
}
if (match == null) return null;
final nB = match["n"] as String?;
final eB = match["e"] as String?;
if (nB == null || eB == null) return null;
return RSAPublicKey(
_bytesToBigInt(_b64UrlBytes(nB)),
_bytesToBigInt(_b64UrlBytes(eB)),
);
}
bool _verifyRs256(RSAPublicKey key, Uint8List input, Uint8List sig) {
final verifier = Signer("SHA-256/RSA") as RSASigner;
verifier.init(false, PublicKeyParameter<RSAPublicKey>(key));
try {
return verifier.verifySignature(input, RSASignature(sig));
} catch (error, stack) {
// a mangled signature throws rather than coming back false.
print("[garage_entitlements] key verify threw: $error");
print(stack);
return false;
}
}
// ---- small codec helpers ----
Map<String, dynamic> _decodeJsonSegment(String seg) {
final bytes = _b64UrlBytes(seg);
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
Uint8List _b64UrlBytes(String s) {
// jwt segments are base64url with the padding stripped — put it back.
var out = s.replaceAll("-", "+").replaceAll("_", "/");
final pad = out.length % 4;
if (pad > 0) out = out.padRight(out.length + (4 - pad), "=");
return base64.decode(out);
}
BigInt _bytesToBigInt(List<int> bytes) {
var r = BigInt.zero;
for (final b in bytes) {
r = (r << 8) | BigInt.from(b & 0xff);
}
return r;
}
DateTime? _epoch(Object? v) {
if (v == null) return null;
final n = v is int ? v : int.tryParse("$v");
if (n == null) return null;
return DateTime.fromMillisecondsSinceEpoch(n * 1000, isUtc: true);
}
@@ -0,0 +1,96 @@
import "dart:convert";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// Where the keys live between runs. ONE blob per project holding the JWKS and
// the whole key set, written together — there is deliberately no "keys but no
// jwks" state, because that is a set of tokens you cannot check.
//
// Same secure storage garage_auth keeps the tokens in. A key is low value (it
// is public-key verifiable and short lived), but keeping it beside the tokens
// means one place to look for sdk state.
/// The seam, so tests and odd platforms can swap the backend out.
abstract class KeyCache {
Future<CachedKeys?> read(String projectSlug);
Future<void> write(String projectSlug, CachedKeys value);
Future<void> clear(String projectSlug);
}
/// The whole cached set for one project. [keys] is sku -> compact JWT.
class CachedKeys {
CachedKeys({required this.keys, required this.jwks});
final Map<String, String> keys;
/// the jwks doc as fetched ( {"keys":[...]} )
final Map<String, dynamic> jwks;
Map<String, dynamic> toJson() => {"keys": keys, "jwks": jwks};
factory CachedKeys.fromJson(Map<String, dynamic> j) => CachedKeys(
keys: Map<String, String>.from(j["keys"] as Map),
jwks: Map<String, dynamic>.from(j["jwks"] as Map),
);
}
class SecureKeyCache implements KeyCache {
SecureKeyCache({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _k(String slug) => "ge.keys.$slug";
@override
Future<CachedKeys?> read(String projectSlug) async {
try {
final raw = await _storage.read(key: _k(projectSlug));
if (raw == null || raw.isEmpty) return null;
return CachedKeys.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (error, stack) {
// a cache we cant read is the same as no cache — say so and carry on.
print("[garage_entitlements] key cache read failed: $error");
print(stack);
return null;
}
}
@override
Future<void> write(String projectSlug, CachedKeys value) async {
try {
await _storage.write(
key: _k(projectSlug),
value: jsonEncode(value.toJson()),
);
} catch (error, stack) {
print("[garage_entitlements] key cache write failed: $error");
print(stack);
}
}
@override
Future<void> clear(String projectSlug) async {
try {
await _storage.delete(key: _k(projectSlug));
} catch (error, stack) {
print("[garage_entitlements] key cache clear failed: $error");
print(stack);
}
}
}
/// In memory. For tests, or anywhere you explicitly dont want persistence.
class MemoryKeyCache implements KeyCache {
final Map<String, CachedKeys> _m = {};
@override
Future<CachedKeys?> read(String projectSlug) async => _m[projectSlug];
@override
Future<void> write(String projectSlug, CachedKeys value) async =>
_m[projectSlug] = value;
@override
Future<void> clear(String projectSlug) async => _m.remove(projectSlug);
}
+174
View File
@@ -0,0 +1,174 @@
// The shapes this package deals in. A key, an entitlement row, and just enough
// of a product to put a price on a buy button.
/// A verified licence key. One entitlement, one key — the key unlocks the thing
/// that was bought and says nothing about the rest of the account.
///
/// Two clocks live on here and they are not the same thing:
///
/// * [expiresAt] is the KEY's expiry. It is the access decision. The server
/// allready clamped it against everything below, so checking it alone is
/// correct.
/// * [entitlementExpiresAt] is when the SUBSCRIPTION runs out, if it ever
/// does. Informational — the thing a UI says out loud ("renews on the 3rd").
class GarageKey {
GarageKey({
required this.subject,
required this.issuer,
required this.project,
required this.sku,
required this.kind,
required this.mode,
required this.issuedAt,
required this.expiresAt,
this.entitlementExpiresAt,
});
/// the user id (`sub`)
final String subject;
final String issuer;
final String project;
final String sku;
/// "one_off" or "subscription"
final String kind;
/// "live" or "sandbox". A sandbox key never satisfies a live check.
final String mode;
final DateTime issuedAt;
/// The key's own expiry (`exp`). THIS is the gate.
final DateTime expiresAt;
/// The entitlement's expiry (`expires_at`), when it has one. Null for
/// something owned outright. Never the gate — for showing a date.
final DateTime? entitlementExpiresAt;
bool get isSubscription => kind == "subscription";
bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt);
/// How long this key has left. Negative once it has gone.
Duration get timeLeft => expiresAt.difference(DateTime.now().toUtc());
}
/// A row from the entitlement ledger. The truth, when you are online.
class GarageEntitlement {
GarageEntitlement({
required this.productId,
required this.kind,
required this.status,
required this.active,
this.projectId,
this.source,
this.expiresAt,
this.graceUntil,
});
final String productId;
final String? projectId;
final String kind;
/// the raw provider state — 'active' | 'trialing' | 'past_due' | 'expired' |
/// 'refunded' | 'revoked'. Read [active] instead, unless you are showing
/// somebody why.
final String status;
final String? source;
/// null = owned outright.
final DateTime? expiresAt;
/// set while a renewal is bouncing; access holds untill it passes.
final DateTime? graceUntil;
/// the server's own verdict — status, expiry and grace allready folded in.
final bool active;
factory GarageEntitlement.fromJson(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
final grace = j["grace_until"] as String?;
return GarageEntitlement(
productId: j["product_id"] as String? ?? "",
projectId: j["project_id"] as String?,
kind: j["kind"] as String? ?? "app",
status: j["status"] as String? ?? "expired",
source: j["source"] as String?,
expiresAt: (exp == null || exp.isEmpty) ? null : DateTime.tryParse(exp),
graceUntil:
(grace == null || grace.isEmpty) ? null : DateTime.tryParse(grace),
active: j["active"] == true,
);
}
}
/// Enough of a product to build a purchase screen. Comes off the public
/// by-sku lookup, so there is no bearer needed and nothing secret in it.
class GarageProduct {
GarageProduct({
required this.id,
required this.sku,
required this.name,
required this.kind,
required this.priceMinor,
required this.currency,
this.description,
this.interval,
this.trialDays,
this.active = true,
});
/// a uuid. Treat it as opaque, dont parse it
final String id;
final String sku;
final String name;
final String? description;
/// 'app' or 'subscription' — the catalogue's own wording, which is not quite
/// the key's `kind`. A key says "one_off"; the catalogue still says "app".
final String kind;
final int priceMinor;
final String currency;
/// 'month' | 'year' | null
final String? interval;
final int? trialDays;
final bool active;
bool get isSubscription => kind == "subscription";
bool get isFree => priceMinor <= 0;
factory GarageProduct.fromJson(Map<String, dynamic> j) => GarageProduct(
id: j["id"] as String? ?? "",
sku: j["sku"] as String? ?? "",
name: j["name"] as String? ?? "",
description: j["description"] as String?,
kind: j["kind"] as String? ?? "app",
priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0,
currency: j["currency"] as String? ?? "gbp",
interval: j["interval"] as String?,
trialDays: (j["trial_days"] as num?)?.toInt(),
active: j["active"] == true || j["active"] == 1,
);
/// a rough display price — no FX, no locale. Your UI does the pretty bit.
String get displayPrice {
if (isFree) return "Free";
return "${currency.toUpperCase()} ${(priceMinor / 100.0).toStringAsFixed(2)}";
}
}
/// Everything this package throws. Network errors from the authed client come
/// up as themselves.
class EntitlementsError implements Exception {
EntitlementsError(this.message, {this.code});
final String message;
final String? code;
@override
String toString() => code == null ? message : "$message ($code)";
}
+36
View File
@@ -0,0 +1,36 @@
name: garage_entitlements
description: "Offline licence keys for Garage apps — fetch a key per entitlement, verify it against the published JWKS, and gate features with no network."
version: 0.1.0
publish_to: 'none'
environment:
sdk: ^3.5.0
flutter: ">=3.5.0"
dependencies:
flutter:
sdk: flutter
garage_auth:
path: ../garage_auth
http: ^1.5.0
# verify reuses the backend's rsa/asn.1 stack, so a key is checked on the
# client the exact way it was signed on the store.
pointycastle: ^4.0.0
# keys + jwks live next to the tokens garage_auth allready keeps there.
flutter_secure_storage: ">=9.2.2 <12.0.0"
# deliberately NOT here: flutter_stripe and url_launcher. this package has to
# build clean on desktop, and neither of them is any of its business —
# portalUrl hands you a Uri and you launch it however you allready do.
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
@@ -0,0 +1,187 @@
import "package:flutter_test/flutter_test.dart";
import "package:garage_entitlements/src/jwks_verify.dart";
import "package:garage_entitlements/src/models.dart";
import "package:pointycastle/export.dart";
import "keys.dart";
void main() {
late RSAPublicKey pub;
late RSAPrivateKey priv;
late Map<String, dynamic> jwks;
setUpAll(() {
final pair = genKey(1);
pub = pair.publicKey as RSAPublicKey;
priv = pair.privateKey as RSAPrivateKey;
jwks = jwksOf(pub);
});
GarageKey verify(String token, {
Map<String, dynamic>? doc,
String iss = "https://pay.imbenji.net",
String project = "field-notes",
String sku = "pro",
String sub = "user-123",
String mode = "live",
}) =>
verifyKey(
token,
doc ?? jwks,
expectedIssuer: iss,
expectedProject: project,
expectedSku: sku,
expectedSub: sub,
expectedMode: mode,
);
String? codeOf(Object? e) => e is EntitlementsError ? e.code : null;
test("verifies a signed key and reads every claim", () {
final ends = "2027-03-01T00:00:00.000Z";
final key = verify(
keyToken(
priv,
kind: "subscription",
entitlementExpiresAt: ends,
),
);
expect(key.subject, "user-123");
expect(key.issuer, "https://pay.imbenji.net");
expect(key.project, "field-notes");
expect(key.sku, "pro");
expect(key.kind, "subscription");
expect(key.mode, "live");
expect(key.isSubscription, isTrue);
expect(key.isExpired, isFalse);
// the two clocks are separate things and both survive the round trip
expect(key.entitlementExpiresAt, DateTime.parse(ends).toUtc());
expect(key.expiresAt.isBefore(DateTime.parse(ends)), isTrue);
});
test("a one-off owned outright carries no entitlement expiry", () {
final key = verify(keyToken(priv));
expect(key.kind, "one_off");
expect(key.entitlementExpiresAt, isNull);
});
test("a tampered signature is refused", () {
final good = keyToken(priv);
final tampered = "${good.substring(0, good.length - 4)}AAAA";
expect(
() => verify(tampered),
throwsA(predicate((e) => codeOf(e) == "bad_signature")),
);
});
test("a key signed by somebody elses key is refused", () {
final other = genKey(9);
final token = keyToken(other.privateKey as RSAPrivateKey);
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "bad_signature")),
);
});
test("iss mismatch", () {
final token = keyToken(priv, iss: "https://not-us.example");
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "iss_mismatch")),
);
});
test("aud mismatch — right project, wrong sku", () {
// the exact thing aud exists to stop: a key for the cheap tier being
// handed to the lock on the expensive one.
final token = keyToken(priv, sku: "basic");
expect(
() => verify(token, sku: "pro"),
throwsA(predicate((e) => codeOf(e) == "aud_mismatch")),
);
});
test("aud mismatch — right sku, wrong project", () {
final token = keyToken(priv, project: "someone-else", sku: "pro");
expect(
() => verify(token, project: "field-notes"),
throwsA(predicate((e) => codeOf(e) == "aud_mismatch")),
);
});
test("aud that isnt project/sku at all", () {
final token = keyToken(priv, audOverride: "field-notes");
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "aud_mismatch")),
);
});
test("sub mismatch — somebody elses key on this device", () {
final token = keyToken(priv, sub: "user-999");
expect(
() => verify(token, sub: "user-123"),
throwsA(predicate((e) => codeOf(e) == "sub_mismatch")),
);
});
test("mode mismatch — a sandbox key never satisfies a live check", () {
final token = keyToken(priv, mode: "sandbox");
expect(
() => verify(token, mode: "live"),
throwsA(predicate((e) => codeOf(e) == "mode_mismatch")),
);
// and the other way, so nobody can force live data into a sandbox build
final live = keyToken(priv, mode: "live");
expect(
() => verify(live, mode: "sandbox"),
throwsA(predicate((e) => codeOf(e) == "mode_mismatch")),
);
});
test("an expired key is refused", () {
final token = keyToken(priv, life: const Duration(hours: -1));
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "expired")),
);
});
test("an unknown kid is refused rather than guessed at", () {
final token = keyToken(priv, kid: "rotated-away");
expect(
() => verify(token),
throwsA(predicate((e) => codeOf(e) == "kid_not_found")),
);
});
test("rotation: the jwks carrying both keys still verifies the old one", () {
final next = genKey(4);
final rotated = {
"keys": [
...(jwksOf(next.publicKey as RSAPublicKey, kid: "k2")["keys"] as List),
...(jwks["keys"] as List),
],
};
final old = keyToken(priv, kid: "k1");
expect(verify(old, doc: rotated).sku, "pro");
});
test("a malformed token is refused, not thrown past", () {
expect(
() => verify("not.a.jwt.at.all"),
throwsA(isA<EntitlementsError>()),
);
expect(() => verify("rubbish"), throwsA(isA<EntitlementsError>()));
});
test("peekAudience splits project and sku without verifying", () {
final aud = peekAudience(keyToken(priv, project: "p", sku: "s"));
expect(aud?.project, "p");
expect(aud?.sku, "s");
expect(peekAudience("rubbish"), isNull);
});
}
+96
View File
@@ -0,0 +1,96 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
// A keypair + a signer, shared by the tests. Lifted from garage_iap's verify
// test — generating a real 2048 bit RSA key beats a fixture, since the whole
// point is that we verify the same way the backend signs.
String b64uBig(BigInt n) {
final bytes = <int>[];
var v = n;
while (v > BigInt.zero) {
bytes.insert(0, (v & BigInt.from(0xff)).toInt());
v = v >> 8;
}
return base64Url.encode(Uint8List.fromList(bytes)).replaceAll("=", "");
}
String b64uStr(String s) => base64Url.encode(utf8.encode(s)).replaceAll("=", "");
String b64uBytes(List<int> b) => base64Url.encode(b).replaceAll("=", "");
AsymmetricKeyPair<PublicKey, PrivateKey> genKey(int seed) {
final rng = SecureRandom("Fortuna")
..seed(
KeyParameter(Uint8List.fromList(List.generate(32, (i) => (i + seed) & 0xff))),
);
final gen = RSAKeyGenerator()
..init(
ParametersWithRandom(
RSAKeyGeneratorParameters(BigInt.parse("65537"), 2048, 64),
rng,
),
);
return gen.generateKeyPair();
}
// build a compact RS256 JWT the same way the backend does — header.payload
// signed PKCS1v15 SHA-256.
String signJwt(
RSAPrivateKey priv,
Map<String, dynamic> header,
Map<String, dynamic> payload,
) {
final h = b64uStr(jsonEncode(header));
final p = b64uStr(jsonEncode(payload));
final input = utf8.encode("$h.$p");
final signer = Signer("SHA-256/RSA") as RSASigner;
signer.init(true, PrivateKeyParameter<RSAPrivateKey>(priv));
final sig = signer.generateSignature(Uint8List.fromList(input));
return "$h.$p.${b64uBytes(sig.bytes)}";
}
Map<String, dynamic> jwksOf(RSAPublicKey pub, {String kid = "k1"}) => {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": kid,
"n": b64uBig(pub.modulus!),
"e": b64uBig(pub.exponent!),
},
],
};
/// A key the way the store mints them. Everything is overridable so a test can
/// break exactly one claim.
String keyToken(
RSAPrivateKey priv, {
String kid = "k1",
String sub = "user-123",
String iss = "https://pay.imbenji.net",
String project = "field-notes",
String sku = "pro",
String kind = "one_off",
String mode = "live",
String? entitlementExpiresAt,
Duration life = const Duration(hours: 1),
String? audOverride,
}) {
final now = DateTime.now().toUtc();
return signJwt(priv, {"alg": "RS256", "kid": kid, "typ": "JWT"}, {
"sub": sub,
"iss": iss,
"aud": audOverride ?? "$project/$sku",
"project": project,
"sku": sku,
"kind": kind,
"mode": mode,
if (entitlementExpiresAt != null) "expires_at": entitlementExpiresAt,
"iat": now.millisecondsSinceEpoch ~/ 1000,
"exp": now.add(life).millisecondsSinceEpoch ~/ 1000,
});
}
+282
View File
@@ -0,0 +1,282 @@
import "dart:convert";
import "package:flutter_test/flutter_test.dart";
import "package:garage_auth/garage_auth.dart";
import "package:garage_entitlements/garage_entitlements.dart";
import "package:http/http.dart" as http;
import "package:http/testing.dart";
import "package:pointycastle/export.dart";
import "keys.dart";
const _issuer = "https://hub.test/auth-api";
const _api = "https://pay.test/api";
const _project = "field-notes";
void main() {
late RSAPublicKey pub;
late RSAPrivateKey priv;
late Map<String, dynamic> jwks;
// what the next /v1/licences call answers with, sku -> token.
late Map<String, String> served;
// set to a body to return instead, for the failure cases.
String? servedRaw;
int licenceCalls = 0;
setUpAll(() {
final pair = genKey(2);
pub = pair.publicKey as RSAPublicKey;
priv = pair.privateKey as RSAPrivateKey;
jwks = jwksOf(pub);
});
setUp(() {
served = {};
servedRaw = null;
licenceCalls = 0;
});
Future<GarageAuth> signedInAuth() async {
final store = MemoryTokenStore();
await store.write("ga.access.test-client", "oauth_fake");
final mock = MockClient((req) async {
final path = req.url.path;
if (path.endsWith("/.well-known/openid-configuration")) {
return http.Response(
jsonEncode({
"authorization_endpoint": "$_issuer/oauth/authorize",
"token_endpoint": "$_issuer/oauth/token",
"userinfo_endpoint": "$_issuer/oauth/userinfo",
}),
200,
headers: {"content-type": "application/json"},
);
}
if (path.endsWith("/oauth/userinfo")) {
return http.Response(
jsonEncode({"sub": "user-123"}),
200,
headers: {"content-type": "application/json"},
);
}
if (path.endsWith("/v1/licences")) {
licenceCalls++;
if (servedRaw != null) return http.Response(servedRaw!, 200);
return http.Response(
jsonEncode({
"licences": [
for (final e in served.entries)
{"sku": e.key, "licence": e.value, "expires_in": 3600},
],
"jwks": jwks,
}),
200,
headers: {"content-type": "application/json"},
);
}
return http.Response(jsonEncode({"error": "nope"}), 404);
});
final auth = GarageAuth(
issuer: _issuer,
clientId: "test-client",
redirectUri: "test://cb",
httpClient: mock,
tokenStore: store,
);
await auth.restore();
return auth;
}
Future<GarageEntitlements> subject({KeyCache? cache}) async => GarageEntitlements(
auth: await signedInAuth(),
projectSlug: _project,
apiBaseUrl: _api,
cache: cache ?? MemoryKeyCache(),
);
String tokenFor(String sku, {Duration life = const Duration(hours: 1)}) =>
keyToken(priv, project: _project, sku: sku, life: life);
test("refresh verifies and holds every key it got", () async {
final ent = await subject();
served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")};
await ent.refresh();
expect(ent.has("pro"), isTrue);
expect(ent.has("extras"), isTrue);
expect(ent.has("never-bought"), isFalse);
expect(ent.key("pro")!.sku, "pro");
});
// THE important one. a cancelled subscription stops coming back in the
// response; if a refresh merged, its key would sit there working untill its
// own exp — which could be a day.
test("refresh REPLACES the set, it does not merge", () async {
final cache = MemoryKeyCache();
final ent = await subject(cache: cache);
served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")};
await ent.refresh();
expect(ent.has("extras"), isTrue);
// they cancelled "extras". it simply isnt in the response any more.
served = {"pro": tokenFor("pro")};
await ent.refresh();
expect(ent.has("pro"), isTrue);
expect(ent.has("extras"), isFalse, reason: "a dropped key must be evicted");
// and it is gone from disk too, not just from memory — otherwise the next
// cold boot would bring it back.
final blob = await cache.read(_project);
expect(blob!.keys.keys, ["pro"]);
});
test("everything gone means everything gone", () async {
final ent = await subject();
served = {"pro": tokenFor("pro")};
await ent.refresh();
expect(ent.has("pro"), isTrue);
served = {};
await ent.refresh();
expect(ent.keys, isEmpty);
});
test("a response with one bad key changes nothing", () async {
final cache = MemoryKeyCache();
final ent = await subject(cache: cache);
served = {"pro": tokenFor("pro")};
await ent.refresh();
// second call carries a key signed by somebody else entirely
final rogue = genKey(11).privateKey as RSAPrivateKey;
served = {
"pro": tokenFor("pro"),
"extras": keyToken(rogue, project: _project, sku: "extras"),
};
await expectLater(ent.refresh(), throwsA(isA<EntitlementsError>()));
// the good set from before is untouched — no half applied refresh.
expect(ent.has("pro"), isTrue);
final blob = await cache.read(_project);
expect(blob!.keys.keys, ["pro"]);
});
test("no jwks in the response is refused", () async {
final ent = await subject();
servedRaw = jsonEncode({"licences": []});
await expectLater(
ent.refresh(),
throwsA(predicate((e) => e is EntitlementsError && e.code == "no_jwks")),
);
});
test("cached() reads the blob back with no network at all", () async {
final cache = MemoryKeyCache();
final first = await subject(cache: cache);
served = {"pro": tokenFor("pro")};
await first.refresh();
final callsAfterRefresh = licenceCalls;
final second = await subject(cache: cache);
await second.cached();
expect(second.has("pro"), isTrue);
// the second instance has its own mock, so this only proves the first one
// wasnt asked again — which is the bit that matters.
expect(licenceCalls, callsAfterRefresh);
});
test("cached() drops an expired key and keeps the rest", () async {
final cache = MemoryKeyCache();
// write a blob by hand: one live key, one that went stale on disk.
await cache.write(
_project,
CachedKeys(
keys: {
"pro": tokenFor("pro"),
"trial": tokenFor("trial", life: const Duration(hours: -1)),
},
jwks: jwks,
),
);
final ent = await subject(cache: cache);
await ent.cached();
expect(ent.has("pro"), isTrue);
expect(ent.has("trial"), isFalse);
});
test("cached() with nothing stored is simply empty", () async {
final ent = await subject();
await ent.cached();
expect(ent.keys, isEmpty);
expect(ent.has("pro"), isFalse);
});
test("clear() empties memory and disk", () async {
final cache = MemoryKeyCache();
final ent = await subject(cache: cache);
served = {"pro": tokenFor("pro")};
await ent.refresh();
await ent.clear();
expect(ent.has("pro"), isFalse);
expect(await cache.read(_project), isNull);
});
test("it notifies, so a ListenableBuilder redraws the gates", () async {
final ent = await subject();
var fired = 0;
ent.addListener(() => fired++);
served = {"pro": tokenFor("pro")};
await ent.refresh();
expect(fired, 1);
served = {};
await ent.refresh();
expect(fired, 2);
});
test("not signed in is an error, not an empty set", () async {
final auth = GarageAuth(
issuer: _issuer,
clientId: "test-client",
redirectUri: "test://cb",
httpClient: MockClient((_) async => http.Response("{}", 200)),
tokenStore: MemoryTokenStore(),
);
await auth.restore();
final ent = GarageEntitlements(
auth: auth,
projectSlug: _project,
apiBaseUrl: _api,
cache: MemoryKeyCache(),
);
await expectLater(
ent.refresh(),
throwsA(
predicate((e) => e is EntitlementsError && e.code == "not_signed_in"),
),
);
});
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+142
View File
@@ -0,0 +1,142 @@
import "package:flutter/material.dart";
import "package:garage_auth/garage_auth.dart";
import "package:garage_iap/garage_iap.dart";
// A tiny store screen: sign in, list products, buy one, show what you own. The
// real wiring (deep-link callback for completeSignIn etc.) is the host app's
// job — see garage_auth's README. This just shows the iap surface.
late final GarageAuth auth;
late final GarageIap iap;
void main() {
auth = GarageAuth(
issuer: "https://hub.imbenji.net/auth-api",
clientId: "example-app",
redirectUri: "exampleapp://auth/callback",
);
iap = GarageIap(
auth: auth,
appSlug: "example-app",
apiBaseUrl: "https://store.imbenji.net/api",
);
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "garage_iap example",
theme: ThemeData(useMaterial3: true),
home: const StorePage(),
);
}
}
class StorePage extends StatefulWidget {
const StorePage({super.key});
@override
State<StorePage> createState() => _StorePageState();
}
class _StorePageState extends State<StorePage> {
List<GarageProduct> _products = [];
String _status = "";
bool _busy = false;
@override
void initState() {
super.initState();
_boot();
}
Future<void> _boot() async {
await auth.restore();
// populate the offline gate cheaply before any network — cachedLicence reads
// straight from secure storage and verifies locally.
try {
await iap.cachedLicence();
} catch (e) {
// expired-offline etc. — fine, just means not entitled until we re-fetch.
print("cached licence not usable: $e");
}
if (auth.isSignedIn) {
await _load();
}
if (mounted) setState(() {});
}
Future<void> _load() async {
setState(() => _busy = true);
try {
_products = await iap.products();
await iap.entitlements();
await iap.licence(); // refresh + cache the offline licence
_status = "loaded";
} catch (e) {
_status = "load failed: $e";
}
if (mounted) setState(() => _busy = false);
}
Future<void> _buy(GarageProduct p) async {
setState(() => _busy = true);
try {
final outcome = await iap.purchase(p, mode: PurchaseMode.sheet);
_status = "purchase: ${outcome.name}";
} on IapError catch (e) {
_status = "purchase error: ${e.message}";
} catch (e) {
_status = "purchase failed: $e";
}
if (mounted) setState(() => _busy = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Store")),
body: !auth.isSignedIn
? Center(
child: ElevatedButton(
onPressed: () => auth.signIn(),
child: const Text("Sign in with Garage"),
),
)
: Column(
children: [
if (_busy) const LinearProgressIndicator(),
Padding(
padding: const EdgeInsets.all(12),
child: Text(_status),
),
Expanded(
child: ListView(
children: [
for (final p in _products)
ListTile(
title: Text(p.name),
subtitle: Text(p.displayPrice),
trailing: iap.has(p.sku)
? const Chip(label: Text("Owned"))
: FilledButton(
onPressed: () => _buy(p),
child: const Text("Buy"),
),
),
],
),
),
],
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
name: garage_iap_example
description: "Minimal example wiring garage_auth + garage_iap together."
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.5.0
flutter: ">=3.5.0"
dependencies:
flutter:
sdk: flutter
garage_auth:
path: ../../garage_auth
garage_iap:
path: ../
dev_dependencies:
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
+25
View File
@@ -0,0 +1,25 @@
// In-app purchasing for Garage apps — layered on garage_auth.
//
// Hand it a signed-in GarageAuth and the store API base url; it lists the app's
// products, runs both purchase modes (embedded Stripe sheet + browser handoff),
// and keeps a short-lived signed licence you can verify offline.
//
// final iap = GarageIap(auth: auth, appSlug: "my-app",
// apiBaseUrl: "https://store.imbenji.net/api");
// final products = await iap.products();
// await iap.purchase(products.first, mode: PurchaseMode.sheet);
// final pro = iap.has("pro");
library;
export "src/garage_iap.dart" show GarageIap;
export "src/models.dart"
show
GarageProduct,
GarageEntitlement,
GarageLicence,
LicenceProduct,
PurchaseMode,
PurchaseOutcome,
IapError;
export "src/licence_cache.dart"
show LicenceCache, SecureLicenceCache, MemoryLicenceCache, CachedLicence;
+499
View File
@@ -0,0 +1,499 @@
import "dart:async";
import "dart:convert";
import "package:flutter/foundation.dart";
import "package:garage_auth/garage_auth.dart";
import "package:http/http.dart" as http;
import "package:url_launcher/url_launcher.dart";
import "jwks_verify.dart";
import "licence_cache.dart";
import "models.dart";
import "sheet/sheet.dart";
// In-app purchasing for one app. Takes a signed-in GarageAuth and the store API
// base url, lists products, runs both purchase flows, and keeps an offline
// licence. It never touches sign-in — that's garage_auth's job; we just borrow
// its authed client so the bearer + refresh are handled for us.
//
// final iap = GarageIap(
// auth: auth,
// appSlug: "my-app",
// apiBaseUrl: "https://store.imbenji.net/api",
// );
// final products = await iap.products();
// await iap.purchase(products.first, mode: PurchaseMode.sheet);
// final pro = iap.has("pro");
//
// it's a ChangeNotifier so UIs can rebuild when entitlements move.
/// Where the payment portal lives. Overridable per call, but this is the one
/// buyers actually get sent to.
const String kGaragePortalBaseUrl = String.fromEnvironment(
"GARAGE_PORTAL_BASE_URL",
defaultValue: "https://pay.imbenji.net",
);
class GarageIap extends ChangeNotifier {
GarageIap({
required this.auth,
required this.appSlug,
required this.apiBaseUrl,
this.merchantName = "Garage",
LicenceCache? licenceCache,
Duration pollTimeout = const Duration(minutes: 3),
}) : _cache = licenceCache ?? SecureLicenceCache(),
_pollTimeout = pollTimeout;
final GarageAuth auth;
final String appSlug;
final String apiBaseUrl;
final String merchantName;
final LicenceCache _cache;
final Duration _pollTimeout;
// last known entitlements from an online fetch. has() reads this first.
List<GarageEntitlement> _entitlements = [];
List<GarageEntitlement> get cachedEntitlements =>
List.unmodifiable(_entitlements);
// product id -> sku, learned from products(). entitlements are keyed by id, so
// we need this to answer has(sku) off an online entitlement.
final Map<String, String> _skuById = {};
// the last verified offline licence, if we've loaded one this session.
GarageLicence? _licence;
Uri _u(String path, [Map<String, String>? q]) {
final base = apiBaseUrl.endsWith("/")
? apiBaseUrl.substring(0, apiBaseUrl.length - 1)
: apiBaseUrl;
return Uri.parse("$base$path").replace(queryParameters: q);
}
// -------------------------------------------------------------------------
// catalogue
// -------------------------------------------------------------------------
// this app's buyable products (paywall + IAP items). active only for buyers.
Future<List<GarageProduct>> products() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/apps/$appSlug/products"));
if (resp.statusCode != 200) {
throw IapError("Could not load products (${resp.statusCode}).",
code: "products_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final list = (body["products"] as List? ?? []);
final out = list
.whereType<Map>()
.map((m) => GarageProduct.fromJson(Map<String, dynamic>.from(m)))
.toList();
for (final p in out) {
_skuById[p.id] = p.sku;
}
return out;
}
// -------------------------------------------------------------------------
// entitlements
// -------------------------------------------------------------------------
// the caller's grants across every app. caches the result so has() can answer
// synchronously afterwards. throws on network failure (offline -> use the
// licence path instead).
Future<List<GarageEntitlement>> entitlements() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/entitlements"));
if (resp.statusCode != 200) {
throw IapError("Could not load entitlements (${resp.statusCode}).",
code: "entitlements_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final list = (body["entitlements"] as List? ?? []);
_entitlements = list
.whereType<Map>()
.map((m) => GarageEntitlement.fromJson(Map<String, dynamic>.from(m)))
.toList();
notifyListeners();
return cachedEntitlements;
}
// quick own/not-own for this app's paywall product. lighter than entitlements().
Future<bool> owned() async {
_requireSignedIn();
final resp = await auth.client.get(_u("/v1/apps/$appSlug/entitlement"));
if (resp.statusCode != 200) {
throw IapError("Could not check entitlement (${resp.statusCode}).",
code: "entitlement_failed");
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return body["entitled"] == true;
}
// -------------------------------------------------------------------------
// purchase
// -------------------------------------------------------------------------
// buy [product]. sheet tries the embedded flutter_stripe sheet and falls back
// to the browser handoff (subscriptions, or platforms with no native sdk).
// handoff always uses the browser. either way we poll entitlements after the
// user-facing step — the webhook is the real source of truth, the redirect /
// sheet-close is just a nudge.
// -------------------------------------------------------------------------
// the payment portal
// -------------------------------------------------------------------------
/// Send the buyer to the Garage Payment Portal for [productId].
///
/// The portal is a different origin, so it cant see this app's token. We mint
/// a one-shot handoff code off the signed-in session and hang it on the url —
/// the portal trades it for the session and the buyer never sees a login.
///
/// [returnUrl] has to be registered on the project or checkout refuses it.
Future<Uri> portalUrl(
String productId, {
String? returnUrl,
String? portalBaseUrl,
}) async {
_requireSignedIn();
final base = portalBaseUrl ?? kGaragePortalBaseUrl;
final trimmed =
base.endsWith("/") ? base.substring(0, base.length - 1) : base;
final query = <String, String>{};
if (returnUrl != null && returnUrl.isNotEmpty) {
query["return_url"] = returnUrl;
}
final code = await _mintHandoffCode();
if (code != null) query["handoff"] = code;
return Uri.parse(
"$trimmed/pay/$productId",
).replace(queryParameters: query.isEmpty ? null : query);
}
/// One-shot code from auth, or null when we couldnt get one — the portal
/// still works then, the buyer just has to sign in when they land.
Future<String?> _mintHandoffCode() async {
try {
final resp = await http.post(
Uri.parse("${auth.issuer}/auth/handoff/issue"),
headers: {"Authorization": "Bearer ${auth.accessToken}"},
);
if (resp.statusCode != 200) {
print(
"[garage_iap] handoff issue refused (${resp.statusCode}): ${resp.body}");
return null;
}
final json = jsonDecode(resp.body) as Map<String, dynamic>;
return json["handoff_code"] as String?;
} catch (error, stack) {
print("[garage_iap] couldnt mint a handoff code: $error");
print(stack);
return null;
}
}
Future<PurchaseOutcome> purchase(
GarageProduct product, {
PurchaseMode mode = PurchaseMode.sheet,
String? returnUrl,
}) async {
_requireSignedIn();
if (mode == PurchaseMode.handoff) {
return _handoff(product, returnUrl);
}
return _sheet(product, returnUrl);
}
// browser handoff: POST /checkout, open the hosted url, poll until granted.
Future<PurchaseOutcome> _handoff(
GarageProduct product, String? returnUrl) async {
final resp = await auth.client.post(
_u("/v1/checkout"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"product_id": product.id,
if (returnUrl != null) "return_url": returnUrl,
}),
);
if (resp.statusCode != 200) {
throw _checkoutError(resp);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final url = body["checkout_url"] as String?;
if (url == null || url.isEmpty) {
throw IapError("Checkout returned no url.", code: "no_checkout_url");
}
final opened = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!opened) {
throw IapError("Could not open the checkout page.",
code: "launch_failed");
}
return _pollForGrant(product);
}
// embedded sheet: stripe-config -> payment-intent -> present. on a subscription
// (or no native sheet) the server / platform tells us to hand off instead.
Future<PurchaseOutcome> _sheet(
GarageProduct product, String? returnUrl) async {
// grab the publishable key up front (also confirms config is reachable).
final cfg = await auth.client.get(_u("/v1/stripe-config"));
if (cfg.statusCode != 200) {
throw IapError("Could not load stripe config (${cfg.statusCode}).",
code: "stripe_config_failed");
}
final pubKey = (jsonDecode(cfg.body)
as Map<String, dynamic>)["publishable_key"] as String?;
final resp = await auth.client.post(
_u("/v1/payment-intent"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"product_id": product.id,
if (returnUrl != null) "return_url": returnUrl,
}),
);
if (resp.statusCode != 200) {
throw _checkoutError(resp);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
// subscription -> server handed back a hosted url. open it like a handoff.
if (body["fallback"] == "handoff") {
final url = body["checkout_url"] as String?;
if (url == null || url.isEmpty) {
throw IapError("Handoff fallback with no url.",
code: "no_checkout_url");
}
final opened =
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
if (!opened) {
throw IapError("Could not open the checkout page.",
code: "launch_failed");
}
return _pollForGrant(product);
}
final clientSecret = body["client_secret"] as String?;
final publishable = (body["publishable_key"] as String?) ?? pubKey;
if (clientSecret == null || publishable == null) {
throw IapError("Payment intent missing client_secret.",
code: "no_client_secret");
}
final result = await presentSheet(SheetParams(
clientSecret: clientSecret,
publishableKey: publishable,
// third-party PI lives on the connected account.
stripeAccount: body["stripe_account"] as String?,
customer: body["customer"] as String?,
ephemeralKey: body["ephemeral_key"] as String?,
merchantName: merchantName,
));
switch (result) {
case SheetResult.unsupported:
// no native sheet here -> degrade to the browser handoff.
return _handoff(product, returnUrl);
case SheetResult.canceled:
return PurchaseOutcome.canceled;
case SheetResult.completed:
return _pollForGrant(product);
}
}
// poll /entitlements until the product shows up active, with a backoff. the
// webhook can lag the redirect by a few seconds, hence the wait. returns
// pending (not an error) if the grant doesnt land before the timeout — it may
// still arrive, the caller can re-check later.
Future<PurchaseOutcome> _pollForGrant(GarageProduct product) async {
final deadline = DateTime.now().add(_pollTimeout);
var wait = const Duration(seconds: 2);
while (DateTime.now().isBefore(deadline)) {
try {
final ents = await entitlements();
final granted = ents.any((e) => e.productId == product.id && e.active);
if (granted) return PurchaseOutcome.granted;
} catch (e) {
// a transient error mid-poll shouldnt kill the whole wait.
print("garage_iap poll error: $e");
}
await Future.delayed(wait);
// gentle backoff, capped so we still check reasonably often.
final next = wait.inMilliseconds * 2;
wait = Duration(milliseconds: next > 8000 ? 8000 : next);
}
return PurchaseOutcome.pending;
}
// -------------------------------------------------------------------------
// offline licence
// -------------------------------------------------------------------------
// Returns a verified licence for this app. Online: fetches the licence JWT AND
// the JWKS in the same trip, caches both, verifies, returns it. Offline (the
// fetch throws): falls back to the cached licence and verifies it locally.
//
// No cached licence + offline => null (treated as not entitled until the first
// online fetch). Cached but past exp while offline => throws expired, also not
// entitled until we can re-fetch.
Future<GarageLicence?> licence({bool forceRefresh = false}) async {
_requireSignedIn();
if (!forceRefresh) {
// try a fresh online fetch first; fall through to cache on any failure.
try {
return await _fetchAndCacheLicence();
} catch (e) {
print("garage_iap licence online fetch failed, trying cache: $e");
}
} else {
return _fetchAndCacheLicence();
}
return _loadCachedLicence();
}
// the cached, offline-verified licence — no network at all. handy for a fast
// boot-time gate before you've been back online.
Future<GarageLicence?> cachedLicence() => _loadCachedLicence();
Future<GarageLicence> _fetchAndCacheLicence() async {
// licence + jwks in the same online trip, so offline always has the key.
final licResp = await auth.client.get(_u("/v1/licence", {"app": appSlug}));
if (licResp.statusCode != 200) {
throw IapError("Licence fetch failed (${licResp.statusCode}).",
code: "licence_failed");
}
final licBody = jsonDecode(licResp.body) as Map<String, dynamic>;
final token = licBody["licence"] as String?;
if (token == null || token.isEmpty) {
throw IapError("No licence in response.", code: "no_licence");
}
// jwks is public, no auth needed — but the authed client works fine for it.
final jwksResp = await auth.client.get(_u("/v1/licence/jwks.json"));
if (jwksResp.statusCode != 200) {
throw IapError("JWKS fetch failed (${jwksResp.statusCode}).",
code: "jwks_failed");
}
final jwks = jsonDecode(jwksResp.body) as Map<String, dynamic>;
final sub = await _subject();
final verified =
verifyLicence(token, jwks, expectedApp: appSlug, expectedSub: sub);
// only cache once it verifies — never persist a bad licence.
await _cache.write(appSlug, CachedLicence(token: token, jwks: jwks));
_licence = verified;
notifyListeners();
return verified;
}
Future<GarageLicence?> _loadCachedLicence() async {
final cached = await _cache.read(appSlug);
if (cached == null) return null;
final sub = await _subject();
// throws on expiry / bad sig — let it propagate so the caller can tell
// "no licence" (null) from "expired offline" (throw).
final verified = verifyLicence(cached.token, cached.jwks,
expectedApp: appSlug, expectedSub: sub);
_licence = verified;
return verified;
}
// resolve the user id we expect in the licence. profile() round-trips userinfo
// — fine, it's cached behind the authed client and only needed at fetch time.
String? _cachedSub;
Future<String> _subject() async {
if (_cachedSub != null) return _cachedSub!;
final me = await auth.profile();
final sub = me?["sub"] as String?;
if (sub == null || sub.isEmpty) {
throw IapError("No subject in profile — cant match the licence.",
code: "no_subject");
}
_cachedSub = sub;
return sub;
}
// -------------------------------------------------------------------------
// has() — the one-liner the apps actually call
// -------------------------------------------------------------------------
// true if [sku] is owned. checks the last online entitlements first, then the
// in-memory verified licence. it's synchronous on purpose — call entitlements()
// or licence() to refresh, then has() to read. for a cold offline start, call
// cachedLicence() once to populate, then has().
bool has(String sku) {
// online: an active entitlement whose product resolves to this sku. needs
// products() to have run so we know the id->sku map.
for (final e in _entitlements) {
if (!e.active) continue;
if (_skuById[e.productId] == sku) return true;
}
// offline: the verified, unexpired licence vouches for the sku directly.
final lic = _licence;
if (lic != null && !lic.isExpired && lic.grants(sku)) {
return true;
}
return false;
}
// -------------------------------------------------------------------------
Future<void> clearLicence() => _cache.clear(appSlug);
void _requireSignedIn() {
if (!auth.isSignedIn) {
throw IapError("Not signed in — call auth.signIn() first.",
code: "not_signed_in");
}
}
IapError _checkoutError(http.Response resp) {
try {
final b = jsonDecode(resp.body) as Map<String, dynamic>;
final code = b["error"] as String?;
final msg = b["message"] as String? ?? "Checkout failed.";
// surface the meaningful ones the backend can return.
if (resp.statusCode == 451) {
return IapError(msg, code: code ?? "region_blocked");
}
if (resp.statusCode == 409) {
return IapError(msg, code: code ?? "connect_not_ready");
}
return IapError(msg, code: code);
} catch (_) {
return IapError("Checkout failed (${resp.statusCode}).",
code: "checkout_failed");
}
}
}
+166
View File
@@ -0,0 +1,166 @@
import "dart:convert";
import "dart:typed_data";
import "package:pointycastle/export.dart";
import "models.dart";
// Offline licence verification. The store signs licences RS256 with its own key
// and publishes the matching public key as a JWKS — we only ever hold the public
// half, so we can verify a licence but never forge one.
//
// We deliberately do NOT pull in a heavy jwt lib here: a JWKS RSA verify is just
// "split the compact token, rebuild the public key from n/e, check the PKCS1v15
// SHA-256 signature over header.payload". pointycastle (the same stack the
// backend signs with) gives us exactly that.
// verifies `token` against `jwks`, then checks the licence is for `expectedApp`
// and `expectedSub` and isnt past exp. throws IapError on any failure so the
// caller can treat "not entitled" uniformly.
GarageLicence verifyLicence(
String token,
Map<String, dynamic> jwks, {
required String expectedApp,
required String expectedSub,
}) {
final parts = token.split(".");
if (parts.length != 3) {
throw IapError("Malformed licence token.", code: "bad_token");
}
final header = _decodeJsonSegment(parts[0]);
final payload = _decodeJsonSegment(parts[1]);
final alg = header["alg"] as String?;
if (alg != "RS256") {
throw IapError("Unexpected licence alg: $alg", code: "bad_alg");
}
final kid = header["kid"] as String?;
final key = _findKey(jwks, kid);
if (key == null) {
// rotation: the licence's kid isnt in the cached jwks. a fresh online fetch
// refreshes the jwks, so this resolves itself next time we're online.
throw IapError("No JWKS key for kid '$kid'.", code: "kid_not_found");
}
final signingInput = utf8.encode("${parts[0]}.${parts[1]}");
final signature = _b64UrlBytes(parts[2]);
if (!_verifyRs256(key, Uint8List.fromList(signingInput), signature)) {
throw IapError("Licence signature failed.", code: "bad_signature");
}
// claims
final sub = payload["sub"] as String?;
final app = payload["app"] as String?;
if (sub == null || sub != expectedSub) {
throw IapError("Licence subject mismatch.", code: "sub_mismatch");
}
if (app == null || app != expectedApp) {
throw IapError("Licence app mismatch.", code: "app_mismatch");
}
final iat = _epoch(payload["iat"]);
final exp = _epoch(payload["exp"]);
if (exp == null) {
throw IapError("Licence has no exp.", code: "no_exp");
}
final products = <LicenceProduct>[];
final rawProducts = payload["products"];
if (rawProducts is List) {
for (final p in rawProducts) {
if (p is Map) {
products.add(LicenceProduct.fromClaim(Map<String, dynamic>.from(p)));
}
}
}
final licence = GarageLicence(
subject: sub,
app: app,
products: products,
issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
expiresAt: exp,
);
// exp check trusts the device clock when offline — an accepted limitation.
if (licence.isExpired) {
throw IapError("Licence expired.", code: "expired");
}
return licence;
}
// ---- key lookup ----
// pull the RSA public key out of the jwks for a given kid. if the licence header
// carried no kid and there's exactly one key, use that.
RSAPublicKey? _findKey(Map<String, dynamic> jwks, String? kid) {
final keys = jwks["keys"];
if (keys is! List || keys.isEmpty) return null;
Map<String, dynamic>? match;
for (final k in keys) {
if (k is! Map) continue;
final m = Map<String, dynamic>.from(k);
if (m["kty"] != "RSA") continue;
if (kid == null || m["kid"] == kid) {
match = m;
break;
}
}
if (match == null) return null;
final nB = match["n"] as String?;
final eB = match["e"] as String?;
if (nB == null || eB == null) return null;
final n = _bytesToBigInt(_b64UrlBytes(nB));
final e = _bytesToBigInt(_b64UrlBytes(eB));
return RSAPublicKey(n, e);
}
bool _verifyRs256(RSAPublicKey key, Uint8List input, Uint8List sig) {
final verifier = Signer("SHA-256/RSA") as RSASigner;
verifier.init(false, PublicKeyParameter<RSAPublicKey>(key));
try {
return verifier.verifySignature(input, RSASignature(sig));
} catch (e) {
// a malformed signature can throw rather than just returning false.
print("garage_iap licence verify threw: $e");
return false;
}
}
// ---- small codec helpers ----
Map<String, dynamic> _decodeJsonSegment(String seg) {
final bytes = _b64UrlBytes(seg);
return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
Uint8List _b64UrlBytes(String s) {
// jwt segments are base64url with the padding stripped — put it back.
var out = s.replaceAll("-", "+").replaceAll("_", "/");
final pad = out.length % 4;
if (pad > 0) out = out.padRight(out.length + (4 - pad), "=");
return base64.decode(out);
}
BigInt _bytesToBigInt(List<int> bytes) {
var r = BigInt.zero;
for (final b in bytes) {
r = (r << 8) | BigInt.from(b & 0xff);
}
return r;
}
DateTime? _epoch(Object? v) {
if (v == null) return null;
final n = v is int ? v : int.tryParse("$v");
if (n == null) return null;
return DateTime.fromMillisecondsSinceEpoch(n * 1000, isUtc: true);
}
+91
View File
@@ -0,0 +1,91 @@
import "dart:convert";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
// Where the offline licence lives. We cache BOTH the licence JWT and the JWKS
// public key in the same trip so offline verification has everything it needs —
// there's deliberately no "licence but no key" state. Keyed by app slug so two
// apps in the same process dont collide.
//
// Reuses secure storage (same backend garage_auth keeps tokens in) — a licence
// is low-value (public-key verifiable, short lived) but keeping it next to the
// tokens means one consistent place for sdk state.
// the small seam, so tests / odd platforms can swap the backend out.
abstract class LicenceCache {
Future<CachedLicence?> read(String appSlug);
Future<void> write(String appSlug, CachedLicence value);
Future<void> clear(String appSlug);
}
class CachedLicence {
CachedLicence({required this.token, required this.jwks});
// the raw compact licence JWT
final String token;
// the jwks doc as fetched ( {"keys":[...]} )
final Map<String, dynamic> jwks;
Map<String, dynamic> toJson() => {"token": token, "jwks": jwks};
factory CachedLicence.fromJson(Map<String, dynamic> j) => CachedLicence(
token: j["token"] as String,
jwks: Map<String, dynamic>.from(j["jwks"] as Map),
);
}
class SecureLicenceCache implements LicenceCache {
SecureLicenceCache({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _key(String slug) => "gi.licence.$slug";
@override
Future<CachedLicence?> read(String appSlug) async {
try {
final raw = await _storage.read(key: _key(appSlug));
if (raw == null || raw.isEmpty) return null;
return CachedLicence.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} catch (e) {
print("garage_iap licence cache read failed: $e");
return null;
}
}
@override
Future<void> write(String appSlug, CachedLicence value) async {
try {
await _storage.write(
key: _key(appSlug), value: jsonEncode(value.toJson()));
} catch (e) {
print("garage_iap licence cache write failed: $e");
}
}
@override
Future<void> clear(String appSlug) async {
try {
await _storage.delete(key: _key(appSlug));
} catch (e) {
print("garage_iap licence cache clear failed: $e");
}
}
}
// in-memory, handy for tests or where you explicitly dont want persistence.
class MemoryLicenceCache implements LicenceCache {
final Map<String, CachedLicence> _m = {};
@override
Future<CachedLicence?> read(String appSlug) async => _m[appSlug];
@override
Future<void> write(String appSlug, CachedLicence value) async =>
_m[appSlug] = value;
@override
Future<void> clear(String appSlug) async => _m.remove(appSlug);
}
+162
View File
@@ -0,0 +1,162 @@
// The buyer-facing data shapes. These mirror the store API json (see
// `productJson` / `entitlementJson` in the backend) but only carry what a
// client actually wants — no created_at/updated_at churn, no raw provider blobs.
// how a purchase is taken. sheet = our embedded flutter_stripe sheet; handoff =
// the system browser to hosted checkout. sheet quietly degrades to handoff for
// subscriptions and on platforms with no native stripe sdk.
enum PurchaseMode { sheet, handoff }
// where a purchase ended up. granted means the entitlement landed (the webhook
// fired and we saw it on poll). pending means we finished the user-facing bit
// but the grant hadnt shown up before we gave up waiting — it may still arrive.
enum PurchaseOutcome { granted, pending, canceled }
class GarageProduct {
GarageProduct({
required this.id,
required this.sku,
required this.name,
required this.kind,
required this.priceMinor,
required this.currency,
this.description,
this.providerPriceId,
this.trialDays,
this.active = true,
});
final String id;
final String sku;
final String name;
final String? description;
// 'app' | 'subscription'. consumables are shelved server side.
final String kind;
final String? providerPriceId;
// price in the currency's minor unit (pence, cents...).
final int priceMinor;
final String currency;
final int? trialDays;
final bool active;
bool get isSubscription => kind == "subscription";
bool get isFree => priceMinor <= 0;
bool get hasTrial => (trialDays ?? 0) > 0;
factory GarageProduct.fromJson(Map<String, dynamic> j) {
return GarageProduct(
id: j["id"] as String,
sku: j["sku"] as String,
name: j["name"] as String,
description: j["description"] as String?,
kind: j["kind"] as String? ?? "app",
providerPriceId: j["provider_price_id"] as String?,
priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0,
currency: (j["currency"] as String? ?? "usd"),
trialDays: (j["trial_days"] as num?)?.toInt(),
active: j["active"] == true || j["active"] == 1,
);
}
// a rough display price. no FX, no locale — the storefront does the pretty
// formatting, this is just a sane default for quick UIs.
String get displayPrice {
if (isFree) return "Free";
final major = priceMinor / 100.0;
return "${currency.toUpperCase()} ${major.toStringAsFixed(2)}";
}
}
class GarageEntitlement {
GarageEntitlement({
required this.productId,
required this.kind,
required this.status,
required this.active,
this.appId,
this.source,
this.expiresAt,
});
final String? appId;
final String productId;
final String kind;
// 'active' | 'trialing' | 'expired' | 'refunded' | 'revoked'
final String status;
final String? source;
// null = perpetual.
final DateTime? expiresAt;
// the server's own verdict (status + expiry) — we trust it rather than
// recomputing the rule on the client.
final bool active;
factory GarageEntitlement.fromJson(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
return GarageEntitlement(
appId: j["app_id"] as String?,
productId: j["product_id"] as String? ?? "",
kind: j["kind"] as String? ?? "app",
status: j["status"] as String? ?? "expired",
source: j["source"] as String?,
expiresAt: exp == null || exp.isEmpty ? null : DateTime.tryParse(exp),
active: j["active"] == true,
);
}
}
// a verified, decoded licence. the products list is the sku/kind set the licence
// vouches for; `has` reads off this when offline.
class GarageLicence {
GarageLicence({
required this.subject,
required this.app,
required this.products,
required this.issuedAt,
required this.expiresAt,
});
final String subject; // the user id (`sub`)
final String app; // the app slug
final List<LicenceProduct> products;
final DateTime issuedAt;
final DateTime expiresAt;
bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt);
bool grants(String sku) => products.any((p) => p.sku == sku);
}
class LicenceProduct {
LicenceProduct({required this.sku, required this.kind, this.expiresAt});
final String sku;
final String kind;
final DateTime? expiresAt;
factory LicenceProduct.fromClaim(Map<String, dynamic> j) {
final exp = j["expires_at"] as String?;
return LicenceProduct(
sku: j["sku"] as String? ?? "",
kind: j["kind"] as String? ?? "app",
expiresAt: exp == null || exp.isEmpty ? null : DateTime.tryParse(exp),
);
}
}
// thrown for the iap-specific failures. network errors from the authed client
// bubble up as-is.
class IapError implements Exception {
IapError(this.message, {this.code});
final String message;
final String? code;
@override
String toString() => code == null ? message : "$message ($code)";
}
+35
View File
@@ -0,0 +1,35 @@
// The embedded purchase sheet seam. flutter_stripe only has a native sheet on
// iOS / Android (and web has its own thing), so the actual impl is conditionally
// imported — native pulls in flutter_stripe, everything else gets a stub that
// reports "unsupported" and lets GarageIap fall back to the browser handoff.
export "sheet_stub.dart" if (dart.library.io) "sheet_io.dart";
// the result of trying to present the sheet.
// completed — the user paid (sheet closed on success). poll entitlements.
// canceled — the user dismissed it.
// unsupported— no native sheet on this platform; caller should handoff.
enum SheetResult { completed, canceled, unsupported }
// what the sheet needs to init + present. mirrors the payment-intent response.
class SheetParams {
SheetParams({
required this.clientSecret,
required this.publishableKey,
this.stripeAccount,
this.customer,
this.ephemeralKey,
this.merchantName = "Garage",
});
final String clientSecret;
final String publishableKey;
// set for third-party (direct charge) sales — confirm on the connected acct.
final String? stripeAccount;
final String? customer;
final String? ephemeralKey;
final String merchantName;
}
+49
View File
@@ -0,0 +1,49 @@
import "dart:io";
import "package:flutter_stripe/flutter_stripe.dart";
import "sheet.dart";
// Native embedded sheet via flutter_stripe. Only iOS + Android actually carry
// the PaymentSheet — desktop dart:io platforms (linux/macos/windows) report
// unsupported so GarageIap drops to the browser handoff there.
Future<SheetResult> presentSheet(SheetParams params) async {
if (!(Platform.isIOS || Platform.isAndroid)) {
return SheetResult.unsupported;
}
// publishable key drives which stripe account the SDK talks to. for a
// third-party direct charge the PI lives on the connected account, so we set
// stripeAccountId too.
Stripe.publishableKey = params.publishableKey;
if (params.stripeAccount != null && params.stripeAccount!.isNotEmpty) {
Stripe.stripeAccountId = params.stripeAccount;
} else {
Stripe.stripeAccountId = null;
}
await Stripe.instance.applySettings();
try {
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: params.clientSecret,
merchantDisplayName: params.merchantName,
customerId: params.customer,
customerEphemeralKeySecret: params.ephemeralKey,
),
);
await Stripe.instance.presentPaymentSheet();
// present completes without throwing -> payment succeeded (or is processing
// and will be finalised by the webhook). either way we go poll entitlements.
return SheetResult.completed;
} on StripeException catch (e) {
// the user backing out is the common, non-error path.
if (e.error.code == FailureCode.Canceled) {
return SheetResult.canceled;
}
print("garage_iap payment sheet error: ${e.error.localizedMessage}");
rethrow;
}
}
+7
View File
@@ -0,0 +1,7 @@
import "sheet.dart";
// Web / linux / anywhere without a native flutter_stripe sheet. Always reports
// unsupported so GarageIap transparently falls back to the browser handoff.
Future<SheetResult> presentSheet(SheetParams params) async {
return SheetResult.unsupported;
}
+40
View File
@@ -0,0 +1,40 @@
name: garage_iap
description: "In-app purchasing for Garage apps — products, both purchase modes (handoff + embedded Stripe sheet), and an offline-verifiable signed licence. Layers on garage_auth."
version: 0.1.0
publish_to: 'none'
environment:
sdk: ^3.5.0
flutter: ">=3.5.0"
dependencies:
flutter:
sdk: flutter
garage_auth:
path: ../garage_auth
http: ^1.5.0
crypto: ^3.0.6
# offline licence verify reuses the backend's asn.1 / rsa stack so a licence
# verifies on the client the exact way it was signed on the store.
pointycastle: ^4.0.0
# licence + jwks cache. tokens already live in secure storage via garage_auth.
flutter_secure_storage: ">=9.2.2 <12.0.0"
# the system-browser handoff for hosted checkout + the subscription fallback.
url_launcher: ^6.3.1
# the embedded white-label purchase sheet. falls back to handoff where there's
# no native sdk (linux, web).
flutter_stripe: ^11.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
+152
View File
@@ -0,0 +1,152 @@
import "dart:convert";
import "dart:typed_data";
import "package:flutter_test/flutter_test.dart";
import "package:garage_iap/src/jwks_verify.dart";
import "package:garage_iap/src/models.dart";
import "package:pointycastle/export.dart";
String _b64uBig(BigInt n) {
final bytes = <int>[];
var v = n;
while (v > BigInt.zero) {
bytes.insert(0, (v & BigInt.from(0xff)).toInt());
v = v >> 8;
}
return base64Url.encode(Uint8List.fromList(bytes)).replaceAll("=", "");
}
String _b64uStr(String s) =>
base64Url.encode(utf8.encode(s)).replaceAll("=", "");
String _b64uBytes(List<int> b) => base64Url.encode(b).replaceAll("=", "");
AsymmetricKeyPair<PublicKey, PrivateKey> _genKey(int seed) {
final rng = SecureRandom("Fortuna")
..seed(KeyParameter(
Uint8List.fromList(List.generate(32, (i) => (i + seed) & 0xff))));
final gen = RSAKeyGenerator()
..init(ParametersWithRandom(
RSAKeyGeneratorParameters(BigInt.parse("65537"), 2048, 64), rng));
return gen.generateKeyPair();
}
// build a compact RS256 JWT the same way the backend does — header.payload
// signed with PKCS1v15 SHA-256.
String _signJwt(RSAPrivateKey priv, Map<String, dynamic> header,
Map<String, dynamic> payload) {
final h = _b64uStr(jsonEncode(header));
final p = _b64uStr(jsonEncode(payload));
final input = utf8.encode("$h.$p");
final signer = Signer("SHA-256/RSA") as RSASigner;
signer.init(true, PrivateKeyParameter<RSAPrivateKey>(priv));
final sig = signer.generateSignature(Uint8List.fromList(input));
return "$h.$p.${_b64uBytes(sig.bytes)}";
}
void main() {
test("verifies a signed licence and reads products", () {
final pair = _genKey(1);
final pub = pair.publicKey as RSAPublicKey;
final priv = pair.privateKey as RSAPrivateKey;
final jwks = {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "k1",
"n": _b64uBig(pub.modulus!),
"e": _b64uBig(pub.exponent!)
}
]
};
final now = DateTime.now().toUtc();
final token = _signJwt(priv, {
"alg": "RS256",
"kid": "k1"
}, {
"sub": "user-123",
"app": "my-app",
"products": [
{"sku": "pro", "kind": "app"}
],
"exp": now.add(const Duration(hours: 24)).millisecondsSinceEpoch ~/ 1000,
});
final lic = verifyLicence(token, jwks,
expectedApp: "my-app", expectedSub: "user-123");
expect(lic.grants("pro"), isTrue);
expect(lic.grants("nope"), isFalse);
expect(lic.isExpired, isFalse);
});
test("rejects wrong app, wrong sub, and a tampered signature", () {
final pair = _genKey(7);
final pub = pair.publicKey as RSAPublicKey;
final priv = pair.privateKey as RSAPrivateKey;
final jwks = {
"keys": [
{
"kty": "RSA",
"kid": "k1",
"alg": "RS256",
"n": _b64uBig(pub.modulus!),
"e": _b64uBig(pub.exponent!)
}
]
};
final now = DateTime.now().toUtc();
final exp =
now.add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000;
final good = _signJwt(priv, {"alg": "RS256", "kid": "k1"},
{"sub": "u", "app": "my-app", "products": [], "exp": exp});
expect(
() => verifyLicence(good, jwks, expectedApp: "other", expectedSub: "u"),
throwsA(isA<IapError>()));
expect(
() => verifyLicence(good, jwks,
expectedApp: "my-app", expectedSub: "someone-else"),
throwsA(isA<IapError>()));
final tampered = "${good.substring(0, good.length - 4)}AAAA";
expect(
() => verifyLicence(tampered, jwks,
expectedApp: "my-app", expectedSub: "u"),
throwsA(isA<IapError>()));
});
test("rejects an expired licence", () {
final pair = _genKey(3);
final pub = pair.publicKey as RSAPublicKey;
final priv = pair.privateKey as RSAPrivateKey;
final jwks = {
"keys": [
{
"kty": "RSA",
"kid": "k1",
"alg": "RS256",
"n": _b64uBig(pub.modulus!),
"e": _b64uBig(pub.exponent!)
}
]
};
final past = DateTime.now()
.toUtc()
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch ~/
1000;
final token = _signJwt(priv, {"alg": "RS256", "kid": "k1"},
{"sub": "u", "app": "my-app", "products": [], "exp": past});
expect(
() =>
verifyLicence(token, jwks, expectedApp: "my-app", expectedSub: "u"),
throwsA(predicate((e) => e is IapError && e.code == "expired")));
});
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+111
View File
@@ -0,0 +1,111 @@
import "package:flutter/widgets.dart";
import "package:garage_ui/scrollbar.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// The application shell for Garage UI apps.
///
/// This deliberately uses [WidgetsApp] rather than [MaterialApp]. Garage UI
/// owns the visual system while Flutter still supplies routing, overlays,
/// media queries, directionality, and localization plumbing.
class GarageApp extends StatelessWidget {
const GarageApp.router({
super.key,
required this.routerConfig,
required this.theme,
this.title = "",
this.builder,
this.locale,
this.localizationsDelegates,
this.supportedLocales = const <Locale>[Locale("en", "US")],
this.debugShowCheckedModeBanner = false,
});
final RouterConfig<Object> routerConfig;
final ThemeData theme;
final String title;
final TransitionBuilder? builder;
final Locale? locale;
final Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates;
final Iterable<Locale> supportedLocales;
final bool debugShowCheckedModeBanner;
@override
Widget build(BuildContext context) {
return WidgetsApp.router(
key: key,
title: title,
routerConfig: routerConfig,
color: theme.colorScheme.background,
locale: locale,
localizationsDelegates: localizationsDelegates,
supportedLocales: supportedLocales,
debugShowCheckedModeBanner: debugShowCheckedModeBanner,
// the Builder matters: without it `builder` is handed the context ABOVE
// this GarageTheme, so GarageTheme.of() asserts inside the very callback
// you were given for theming.
// ScrollConfiguration, not WidgetsApp's `scrollBehavior` - that
// parameter only exists on MaterialApp/CupertinoApp.
builder: (context, child) => ScrollConfiguration(
behavior: const GarageScrollBehavior(),
child: GarageTheme(
data: theme,
child: Builder(
builder: (themedContext) =>
builder?.call(themedContext, child) ??
child ??
const SizedBox.shrink(),
),
),
),
);
}
}
/// Scrolling without Material's overscroll glow.
///
/// The default ScrollBehavior hangs a GlowingOverscrollIndicator off every
/// scrollable on Android and Fuchsia - the grey arc that swells out of the
/// top or bottom edge when you drag past the end. Its a Material idiom, and
/// this kit isnt a Material app: it paints a big soft shape over flat chrome
/// and reads as a rendering fault rather than as feedback.
///
/// Physics and drag devices are untouched, so a list still throws and settles
/// the way the OS expects - it just doesnt glow at the ends. iOS and desktop
/// never had the glow in the first place, so this only levels the other
/// platforms up to what they already do.
///
/// The scrollbar is ours too. The base behaviour hands desktop a stock
/// [RawScrollbar] - 8px, square ends, a hardcoded grey that knows nothing
/// about the scheme - and thats the pale bar that used to sit beside every
/// nav list. [GarageScrollbar] replaces it here, once, for every scrollable
/// in the app. Touch platforms keep no bar at all, same as the default.
class GarageScrollBehavior extends ScrollBehavior {
const GarageScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) => child;
@override
Widget buildScrollbar(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
switch (getPlatform(context)) {
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
return GarageScrollbar(controller: details.controller, child: child);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return child;
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import "dart:ui" as ui;
import "package:flutter/foundation.dart";
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
// Wraps the whole app in a RepaintBoundary we can snapshot on demand, so an
// eyedropper has something to read pixels out of on platforms with no system
// colour sampler (see platform/eyedropper.dart for the ones that do).
//
// This obviously only covers whats inside the flutter window - thats the
// tradeoff for it working everywhere. Mounted once, in main.dart, just under
// CustomCursorLayer so the fake cursor doesnt end up baked into the snapshot
// and get sampled by the very thing thats drawing it.
class AppFrameCapture extends StatelessWidget {
const AppFrameCapture({super.key, required this.child});
static final GlobalKey _boundaryKey = GlobalKey(
debugLabel: "app frame capture",
);
final Widget child;
@override
Widget build(BuildContext context) {
return RepaintBoundary(key: _boundaryKey, child: child);
}
/// Grabs the current frame. Returns null (and logs why) if theres nothing
/// to grab - no boundary mounted yet, or the raster came back empty.
static Future<AppFrameSnapshot?> capture() async {
final object = _boundaryKey.currentContext?.findRenderObject();
if (object is! RenderRepaintBoundary) {
debugPrint(
"AppFrameCapture.capture: no boundary mounted, is AppFrameCapture in "
"the tree?",
);
return null;
}
ui.Image? image;
try {
// pixelRatio 1 on purpose - it keeps image pixels and logical pixels the
// same thing, so colorAt can index straight off a pointer position with
// no dpr maths, and it keeps the byte buffer a quarter of the size on a
// retina display.
image = await object.toImage();
final width = image.width;
final height = image.height;
final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (bytes == null) {
debugPrint("AppFrameCapture.capture: toByteData returned null");
return null;
}
return AppFrameSnapshot._(
bytes.buffer.asUint8List(),
width,
height,
object.localToGlobal(Offset.zero),
);
} catch (error, stack) {
debugPrint("AppFrameCapture.capture failed: $error\n$stack");
return null;
} finally {
image?.dispose();
}
}
}
/// A frozen copy of the app window you can read single pixels out of.
class AppFrameSnapshot {
AppFrameSnapshot._(this._pixels, this.width, this.height, this.origin);
/// straight-from-bytes ctor, only so the sampling maths can be probed
/// without standing up a whole render tree.
@visibleForTesting
AppFrameSnapshot.fromRawRgba({
required Uint8List pixels,
required this.width,
required this.height,
this.origin = Offset.zero,
}) : _pixels = pixels;
final Uint8List _pixels;
final int width;
final int height;
/// where the captured boundary sits in global coordinates. normally zero,
/// but not worth assuming.
final Offset origin;
/// The colour at [globalPosition], or null if thats outside the captured
/// area or lands on a fully transparent pixel.
Color? colorAt(Offset globalPosition) {
final local = globalPosition - origin;
final x = local.dx.floor();
final y = local.dy.floor();
if (x < 0 || y < 0 || x >= width || y >= height) return null;
final i = (y * width + x) * 4;
final a = _pixels[i + 3];
if (a == 0) return null;
// rawRgba is premultiplied, so anything drawn over a translucent layer
// reads darker than it looks unless we undo that first.
int channel(int offset) {
final value = _pixels[i + offset];
if (a == 255) return value;
return (value * 255 / a).round().clamp(0, 255);
}
return Color.fromARGB(255, channel(0), channel(1), channel(2));
}
}
+235
View File
@@ -0,0 +1,235 @@
import "package:flutter/widgets.dart";
sealed class AppMenuItem {
const AppMenuItem();
}
class AppMenuGroup extends AppMenuItem {
const AppMenuGroup({
required this.label,
required this.children,
this.icon,
this.visible = true,
});
final String label;
final IconData? icon;
final List<AppMenuItem> children;
final bool visible;
List<AppMenuItem> get visibleChildren => children.where((item) {
return switch (item) {
AppMenuGroup group => group.visible,
AppMenuAction action => action.visible,
_ => true,
};
}).toList();
}
class AppMenuAction extends AppMenuItem {
const AppMenuAction({
required this.label,
this.icon,
this.trailingIcon,
this.onTap,
this.shortcut,
this.visible = true,
});
final String label;
final IconData? icon;
final IconData? trailingIcon;
final VoidCallback? onTap;
final SingleActivator? shortcut;
final bool visible;
bool get enabled => onTap != null;
}
class AppMenuCheck extends AppMenuItem {
const AppMenuCheck({
required this.label,
required this.checked,
this.checkedLabel,
this.onToggle,
this.shortcut,
});
final String label;
final String? checkedLabel;
final bool checked;
final VoidCallback? onToggle;
final SingleActivator? shortcut;
}
class AppMenuPlatformProvided extends AppMenuItem {
const AppMenuPlatformProvided(this.type);
final PlatformProvidedMenuItemType type;
}
class AppMenuSeparator extends AppMenuItem {
const AppMenuSeparator({this.visible = true});
final bool visible;
}
/// Converts the shared menu model to Flutter's native platform menu model.
class AppMenuNativeRenderer {
static List<PlatformMenuItem> build(
List<AppMenuGroup> menus, {
String appName = "Garage App",
}) {
final appItems = <PlatformMenuItem>[
for (final type in [
PlatformProvidedMenuItemType.about,
PlatformProvidedMenuItemType.servicesSubmenu,
PlatformProvidedMenuItemType.hide,
PlatformProvidedMenuItemType.hideOtherApplications,
PlatformProvidedMenuItemType.showAllApplications,
PlatformProvidedMenuItemType.quit,
])
if (PlatformProvidedMenuItem.hasMenu(type))
PlatformProvidedMenuItem(type: type),
];
return [
if (appItems.isNotEmpty) PlatformMenu(label: appName, menus: appItems),
for (final group in menus)
if (group.visible)
PlatformMenu(
label: group.label,
menus: _convertItems(group.visibleChildren),
),
];
}
static String signature(List<AppMenuGroup> menus) {
final buffer = StringBuffer();
for (final group in menus) {
if (!group.visible) continue;
_writeSignature(buffer, group);
buffer.write(";");
}
return buffer.toString();
}
static List<PlatformMenuItem> _convertItems(List<AppMenuItem> items) {
final groups = <List<AppMenuItem>>[<AppMenuItem>[]];
for (final item in items) {
if (item case AppMenuSeparator(visible: true)) {
groups.add(<AppMenuItem>[]);
} else if (item is! AppMenuSeparator) {
groups.last.add(item);
}
}
final result = <PlatformMenuItem>[];
for (var index = 0; index < groups.length; index++) {
final native = groups[index]
.map(_toNativeItem)
.whereType<PlatformMenuItem>();
final items = native.toList();
if (items.isEmpty) continue;
if (index == 0) {
result.addAll(items);
} else {
result.add(PlatformMenuItemGroup(members: items));
}
}
return result;
}
static PlatformMenuItem? _toNativeItem(AppMenuItem item) {
return switch (item) {
AppMenuGroup group when group.visible => PlatformMenu(
label: group.label,
menus: _convertItems(group.visibleChildren),
),
AppMenuAction action when action.visible => PlatformMenuItem(
label: action.label,
shortcut: action.shortcut,
onSelected: action.onTap,
),
AppMenuCheck check => PlatformMenuItem(
label: check.checked && check.checkedLabel != null
? check.checkedLabel!
: check.label,
shortcut: check.shortcut,
onSelected: check.onToggle,
),
AppMenuPlatformProvided provided
when PlatformProvidedMenuItem.hasMenu(provided.type) =>
PlatformProvidedMenuItem(type: provided.type),
_ => null,
};
}
static void _writeSignature(StringBuffer buffer, AppMenuItem item) {
switch (item) {
case AppMenuGroup group:
if (!group.visible) return;
buffer
..write("G(")
..write(group.label)
..write("|")
..write(group.icon?.codePoint ?? -1)
..write(")[");
for (final child in group.visibleChildren) {
_writeSignature(buffer, child);
buffer.write(",");
}
buffer.write("]");
case AppMenuAction action:
if (!action.visible) return;
buffer
..write("A(")
..write(action.label)
..write("|")
..write(action.icon?.codePoint ?? -1)
..write("|")
..write(action.enabled ? 1 : 0)
..write("|")
..write(_shortcutSignature(action.shortcut))
..write(")");
case AppMenuCheck check:
buffer
..write("C(")
..write(check.label)
..write("|")
..write(check.checkedLabel ?? "-")
..write("|")
..write(check.checked ? 1 : 0)
..write("|")
..write(check.onToggle != null ? 1 : 0)
..write("|")
..write(_shortcutSignature(check.shortcut))
..write(")");
case AppMenuPlatformProvided provided:
buffer
..write("P(")
..write(provided.type.name)
..write(")");
case AppMenuSeparator separator:
if (separator.visible) buffer.write("S");
}
}
// SingleActivator has no value-based toString (it's Diagnosticable, so the
// default one embeds the object's identity hash) - and menu_def.dart builds
// a fresh activator on every rebuild, so hashing the instance itself would
// make the signature change every frame and defeat the whole point of this
// gate. build it off the actual key/modifier fields instead, those are what
// we care about being stable.
static String _shortcutSignature(SingleActivator? shortcut) {
if (shortcut == null) return "-";
return [
shortcut.trigger.keyLabel,
shortcut.trigger.keyId.toRadixString(16),
shortcut.alt ? "a" : "",
shortcut.control ? "c" : "",
shortcut.meta ? "m" : "",
shortcut.shift ? "s" : "",
].join(":");
}
}
+67
View File
@@ -0,0 +1,67 @@
import "package:flutter/foundation.dart";
import "package:flutter/widgets.dart";
class AppMenuNotifier extends ChangeNotifier {
List<PlatformMenuItem> _menus = const [];
List<PlatformMenuItem> get menus => _menus;
void update(List<PlatformMenuItem> menus) {
_menus = menus;
notifyListeners();
}
}
/// Keeps one native platform menu host above an application's provider tree.
class PlatformMenuHost extends StatefulWidget {
const PlatformMenuHost({
super.key,
required this.notifier,
required this.child,
});
final AppMenuNotifier notifier;
final Widget child;
@override
State<PlatformMenuHost> createState() => _PlatformMenuHostState();
}
class _PlatformMenuHostState extends State<PlatformMenuHost> {
List<PlatformMenuItem> _menus = const [];
static bool get _supported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.macOS;
@override
void initState() {
super.initState();
_menus = widget.notifier.menus;
widget.notifier.addListener(_onMenusChanged);
}
@override
void didUpdateWidget(PlatformMenuHost oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.notifier == oldWidget.notifier) return;
oldWidget.notifier.removeListener(_onMenusChanged);
widget.notifier.addListener(_onMenusChanged);
_menus = widget.notifier.menus;
}
@override
void dispose() {
widget.notifier.removeListener(_onMenusChanged);
super.dispose();
}
void _onMenusChanged() {
if (mounted) setState(() => _menus = widget.notifier.menus);
}
@override
Widget build(BuildContext context) {
if (!_supported) return widget.child;
return PlatformMenuBar(menus: _menus, child: widget.child);
}
}
File diff suppressed because it is too large Load Diff
+799
View File
@@ -0,0 +1,799 @@
import "dart:math" as math;
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/platform/eyedropper.dart";
import "package:garage_ui/app_frame_capture.dart";
import "package:garage_ui/button.dart";
import "package:garage_ui/extensions.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/text_field.dart";
// hand rolled stand-in for shadcn's ColorInput. it isnt a 1:1 port of their
// picker but it does the same job the lines panel needs: a swatch you click to
// open an SV square + hue slider, streaming onChanging while you drag and
// firing onChanged when the popover closes.
enum PromptMode { popover, dialog }
// wraps a colour as HSV so dragging stays smooth (avoids the rounding wobble you
// get bouncing through rgb every frame).
class ColorDerivative {
ColorDerivative(this.hsv);
factory ColorDerivative.fromColor(Color color) =>
ColorDerivative(HSVColor.fromColor(color));
final HSVColor hsv;
Color toColor() => hsv.toColor();
ColorDerivative withHSV(HSVColor v) => ColorDerivative(v);
}
class ColorInput extends StatefulWidget {
const ColorInput({
super.key,
required this.value,
this.onChanged,
this.onChanging,
this.promptMode = PromptMode.popover,
this.popoverAlignment = Alignment.bottomLeft,
this.showAlpha = true,
});
final ColorDerivative value;
final ValueChanged<ColorDerivative>? onChanged;
final ValueChanged<ColorDerivative>? onChanging;
final PromptMode promptMode;
final Alignment popoverAlignment;
final bool showAlpha;
@override
State<ColorInput> createState() => _ColorInputState();
}
class _ColorInputState extends State<ColorInput> {
final LayerLink _link = LayerLink();
final TextEditingController _hexController = TextEditingController();
final TextEditingController _hueController = TextEditingController();
final TextEditingController _saturationController = TextEditingController();
final TextEditingController _valueController = TextEditingController();
final TextEditingController _redController = TextEditingController();
final TextEditingController _greenController = TextEditingController();
final TextEditingController _blueController = TextEditingController();
final TextEditingController _alphaController = TextEditingController();
OverlayEntry? _entry;
late HSVColor _hsv;
var _usesHsvChannels = true;
double _maxPopoverWidth = _pickerWidth;
double _maxPopoverHeight = double.infinity;
// eyedropper session state. _pickAnchor is the colour we snap back to if the
// pick gets cancelled, _pickEntry/_pickFrame only exist for the in-app
// fallback path (the native samplers own their own overlay).
OverlayEntry? _pickEntry;
AppFrameSnapshot? _pickFrame;
HSVColor? _pickAnchor;
var _isPicking = false;
// bumped every time a session starts or gets abandoned. the native sampler
// is owned by the OS and cant be dismissed from here, so cancelling one is
// really just "ignore whatever it eventually hands back".
var _pickSession = 0;
// These are deliberately kept in step with `_picker`. We need its bounds
// before building the overlay so that the follower can choose an edge that
// remains visible in a narrow inspector panel.
static const _pickerWidth = 200.0;
static const _pickerHeightWithoutAlpha = 420.0;
static const _pickerHeightWithAlpha = 460.0;
@override
void initState() {
super.initState();
_hsv = widget.value.hsv;
_syncHexValue();
_syncChannelValues();
}
@override
void didUpdateWidget(covariant ColorInput old) {
super.didUpdateWidget(old);
// only follow external changes while the popover is closed, otherwise our
// own drags fight the incoming value.
if (_entry == null) {
_hsv = widget.value.hsv;
_syncHexValue();
_syncChannelValues();
}
}
void _emitChanging(HSVColor v) {
setState(() => _hsv = v);
_syncHexValue();
_syncChannelValues();
widget.onChanging?.call(ColorDerivative(v));
_entry?.markNeedsBuild();
}
void _open() {
if (_entry != null) return;
// A colour input often lives inside a panel that has its own Overlay. A
// popup inserted there is clipped at the panel boundary, even though there
// is room elsewhere in the application window. Use the root overlay so
// the picker can safely escape its containing panel.
final overlay = Overlay.of(context, rootOverlay: true);
final theme = GarageTheme.of(context);
_syncHexValue();
final placement = _placementFor(overlay);
_maxPopoverWidth = placement.maxWidth;
_maxPopoverHeight = placement.maxHeight;
_entry = OverlayEntry(
builder: (ctx) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: _close,
),
),
CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: placement.targetAnchor,
followerAnchor: placement.followerAnchor,
offset: placement.offset,
// The root Overlay can sit above GarageTheme in the widget tree.
// Preserve the caller's theme so the popup remains buildable.
child: GarageTheme(data: theme, child: _picker(theme)),
),
],
);
},
);
overlay.insert(_entry!);
}
_PopoverPlacement _placementFor(OverlayState overlay) {
final target = context.findRenderObject()! as RenderBox;
final overlayBox = overlay.context.findRenderObject()! as RenderBox;
final targetOffset = target.localToGlobal(
Offset.zero,
ancestor: overlayBox,
);
final overlaySize = overlayBox.size;
final targetSize = target.size;
const margin = 8.0;
const gap = 6.0;
final pickerHeight = widget.showAlpha
? _pickerHeightWithAlpha
: _pickerHeightWithoutAlpha;
final preferLeft = widget.popoverAlignment.x <= 0;
final roomOnRight = overlaySize.width - targetOffset.dx;
final roomOnLeft = targetOffset.dx + targetSize.width;
final openToRight = preferLeft
? roomOnRight >= _pickerWidth + margin || roomOnRight >= roomOnLeft
: roomOnRight > roomOnLeft;
final preferBelow = widget.popoverAlignment.y >= 0;
final roomBelow = overlaySize.height - targetOffset.dy - targetSize.height;
final roomAbove = targetOffset.dy;
final openBelow = preferBelow
? roomBelow >= pickerHeight + margin || roomBelow >= roomAbove
: roomBelow > roomAbove;
final availableHeight = (openBelow ? roomBelow : roomAbove) - gap - margin;
final availableWidth = (openToRight ? roomOnRight : roomOnLeft) - margin;
return _PopoverPlacement(
targetAnchor: Alignment(openToRight ? -1 : 1, openBelow ? 1 : -1),
followerAnchor: Alignment(openToRight ? -1 : 1, openBelow ? -1 : 1),
offset: Offset(0, openBelow ? gap : -gap),
maxWidth: math.max(0.0, availableWidth),
maxHeight: math.max(0.0, availableHeight),
);
}
void _close() {
// shouldnt normally happen (the pick surface sits above the popovers own
// dismiss barrier) but leaving a full screen overlay behind would be nasty
if (_pickEntry != null) _endInAppPick(commit: false);
_entry?.remove();
_entry = null;
widget.onChanged?.call(ColorDerivative(_hsv));
}
@override
void dispose() {
_entry?.remove();
_entry = null;
_pickEntry?.remove();
_pickEntry = null;
_pickFrame = null;
_hexController.dispose();
_hueController.dispose();
_saturationController.dispose();
_valueController.dispose();
_redController.dispose();
_greenController.dispose();
_blueController.dispose();
_alphaController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return CompositedTransformTarget(
link: _link,
child: Semantics(
container: true,
button: true,
label: "Colour",
onTap: _open,
child: GestureDetector(
onTap: _open,
excludeFromSemantics: true,
child: Container(
width: 40 * theme.scaling,
height: theme.density.controlHeight,
padding: EdgeInsets.all(theme.density.controlBorderWidth),
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(theme.radiusMd),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: _hsv.toColor(),
borderRadius: BorderRadius.circular(
math.max(
0,
theme.radiusMd - theme.density.controlBorderWidth,
),
),
),
),
),
),
),
);
}
Widget _picker(ThemeData theme) {
final density = theme.density;
return Container(
width: math.min(_pickerWidth, _maxPopoverWidth),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.popover,
borderRadius: theme.borderRadiusLg,
border: Border.all(
color: theme.colorScheme.popoverBorder,
width: theme.scaling,
),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: _maxPopoverHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_wheelAndValueSlider(),
SizedBox(height: density.containerGap),
ButtonGroup.horizontal(
children: [
Expanded(
child: _usesHsvChannels
? Button.secondary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = false),
child: const Text("RGB"),
)
: Button.primary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = true),
child: const Text("RGB"),
),
),
Expanded(
child: _usesHsvChannels
? Button.primary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = false),
child: const Text("HSV"),
)
: Button.secondary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = true),
child: const Text("HSV"),
),
),
],
),
SizedBox(height: density.containerGap),
ButtonGroup.vertical(
children: [
if (_usesHsvChannels) ...[
_channelRow(
"Hue",
_hueController,
(value) => _hsv.withHue(value * 360),
),
_channelRow(
"Saturation",
_saturationController,
_hsv.withSaturation,
),
_channelRow("Value", _valueController, _hsv.withValue),
] else ...[
_rgbChannelRow("Red", _redController, 16),
_rgbChannelRow("Green", _greenController, 8),
_rgbChannelRow("Blue", _blueController, 0),
],
if (widget.showAlpha)
_channelRow("Alpha", _alphaController, _hsv.withAlpha),
],
),
SizedBox(height: density.containerGap),
_hexRow(),
],
),
),
),
);
}
Widget _wheelAndValueSlider() {
return SizedBox(
height: 160,
child: Row(
children: [
Expanded(child: _hueSaturationWheel()),
const SizedBox(width: 8),
SizedBox(width: 16, height: 160, child: _valueSlider()),
],
),
);
}
Widget _hueSaturationWheel() {
return LayoutBuilder(
builder: (context, constraints) {
final side = math.min(constraints.maxWidth, constraints.maxHeight);
void handle(Offset local) {
final center = Offset(side / 2, side / 2);
final delta = local - center;
final saturation = (delta.distance / (side / 2)).clamp(0.0, 1.0);
final hue =
(math.atan2(delta.dy, delta.dx) * 180 / math.pi + 360) % 360;
_emitChanging(_hsv.withHue(hue).withSaturation(saturation));
}
final angle = _hsv.hue * math.pi / 180;
final thumbOffset = Offset(
side / 2 + math.cos(angle) * (side / 2) * _hsv.saturation,
side / 2 + math.sin(angle) * (side / 2) * _hsv.saturation,
);
return Center(
child: SizedBox(
width: side,
height: side,
child: GestureDetector(
// colour wheel drag surface, not a control - keep it out of the
// semantics tree so it doesn't read as a bogus scrollable
excludeFromSemantics: true,
onPanDown: (details) => handle(details.localPosition),
onPanUpdate: (details) => handle(details.localPosition),
child: CustomPaint(
painter: _ColorWheelPainter(),
child: Stack(
children: [
Positioned(
left: thumbOffset.dx - 8,
top: thumbOffset.dy - 8,
child: _thumb(),
),
],
),
),
),
),
);
},
);
}
Widget _valueSlider() {
return LayoutBuilder(
builder: (context, constraints) {
const handleHeight = 10.0;
final handleTop =
((1 - _hsv.value) * (constraints.maxHeight - handleHeight)).clamp(
0.0,
constraints.maxHeight - handleHeight,
);
void handle(Offset local) => _emitChanging(
_hsv.withValue(
(1 - local.dy / constraints.maxHeight).clamp(0.0, 1.0),
),
);
return GestureDetector(
// colour wheel drag surface, not a control - keep it out of the
// semantics tree so it doesn't read as a bogus scrollable
excludeFromSemantics: true,
onPanDown: (details) => handle(details.localPosition),
onPanUpdate: (details) => handle(details.localPosition),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: GarageTheme.of(
context,
).colorScheme.controlBorder,
),
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_hsv.withValue(1).toColor(),
const Color(0xFF000000),
],
),
),
),
),
),
Positioned(
left: -2,
right: -2,
top: handleTop,
height: handleHeight,
child: Container(
decoration: BoxDecoration(
color: _hsv.toColor(),
border: Border.all(color: const Color(0xFFFFFFFF)),
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
);
},
);
}
Widget _channelRow(
String label,
TextEditingController controller,
HSVColor Function(double value) update,
) {
return TextField(
controller: controller,
textAlign: TextAlign.end,
variant: TextFieldVariant.secondary,
keyboardType: TextInputType.number,
features: [
InputFeature.leading(Text(label)),
InputFeature.fillIndicator(min: 0, max: 1),
InputFeature.scrub(
sensitivity: 0.01,
decimals: 3,
onUpdate: (text) => _setChannel(text, update),
),
],
onChanged: (text) => _setChannel(text, update),
);
}
Widget _rgbChannelRow(
String label,
TextEditingController controller,
int shift,
) {
return TextField(
controller: controller,
textAlign: TextAlign.end,
variant: TextFieldVariant.secondary,
keyboardType: TextInputType.number,
features: [
InputFeature.leading(Text(label)),
InputFeature.fillIndicator(min: 0, max: 1),
InputFeature.scrub(
sensitivity: 0.01,
decimals: 3,
onUpdate: (text) => _setRgbChannel(text, shift),
),
],
onChanged: (text) => _setRgbChannel(text, shift),
);
}
void _updatePicker(VoidCallback update) {
setState(update);
_entry?.markNeedsBuild();
}
Widget _hexRow() {
final density = GarageTheme.of(context).density;
return Row(
children: [
const Text("Hex").muted(),
SizedBox(width: density.controlGap * 2),
Expanded(
child: TextField(
controller: _hexController,
textAlign: TextAlign.center,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp("[0-9a-fA-F#]")),
],
onChanged: _setHex,
),
),
SizedBox(width: density.controlGap),
// same toggle-to-cancel behaviour the object eyedropper in
// object_field.dart uses - pressing it again while armed backs out
_isPicking
? IconButton.primary(
icon: Icon(LucideIcons.pipette).iconSmall,
onPressed: _cancelEyedropper,
)
: IconButton.secondary(
icon: Icon(LucideIcons.pipette).iconSmall,
onPressed: _startEyedropper,
),
],
);
}
// ---- eyedropper -------------------------------------------------------
Future<void> _startEyedropper() async {
if (_isPicking) return;
final session = ++_pickSession;
_pickAnchor = _hsv;
_updatePicker(() => _isPicking = true);
// macOS / chromium can sample the whole desktop. Their samplers draw
// their own magnified loupe and only report the pixel you settle on, so
// theres nothing to stream back as a preview on this path.
if (await nativeEyedropperAvailable()) {
final picked = await pickNativeScreenColor();
if (!mounted || session != _pickSession) return;
_updatePicker(() => _isPicking = false);
_pickAnchor = null;
if (picked != null) _emitChanging(HSVColor.fromColor(picked));
return;
}
if (!mounted || session != _pickSession) return;
await _startInAppPick();
}
// fallback for platforms with no system sampler: freeze a copy of our own
// window and read pixels out of that while the cursor moves over it.
Future<void> _startInAppPick() async {
final frame = await AppFrameCapture.capture();
if (!mounted) return;
if (frame == null) {
// capture() already said why
_updatePicker(() => _isPicking = false);
_pickAnchor = null;
return;
}
_pickFrame = frame;
final theme = GarageTheme.of(context);
_pickEntry = OverlayEntry(
builder: (ctx) => GarageTheme(data: theme, child: _pickSurface()),
);
Overlay.of(context, rootOverlay: true).insert(_pickEntry!);
_updatePicker(() {});
}
Widget _pickSurface() {
return Positioned.fill(
child: Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey != LogicalKeyboardKey.escape) {
return KeyEventResult.ignored;
}
_cancelEyedropper();
return KeyEventResult.handled;
},
child: MouseRegion(
cursor: SystemMouseCursors.precise,
opaque: true,
onHover: (event) => _previewPickAt(event.position),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) {
_previewPickAt(event.position);
_endInAppPick(commit: true);
},
child: const SizedBox.expand(),
),
),
),
);
}
void _previewPickAt(Offset globalPosition) {
final color = _pickFrame?.colorAt(globalPosition);
if (color == null) return;
_emitChanging(HSVColor.fromColor(color));
}
void _cancelEyedropper() {
if (_pickEntry != null) {
_endInAppPick(commit: false);
return;
}
// the native sampler stays on screen until the user deals with it (its the
// OSs window, not ours) - all we can do is disown the session so whatever
// it eventually reports gets dropped on the floor.
_pickSession++;
_pickAnchor = null;
_updatePicker(() => _isPicking = false);
}
void _endInAppPick({required bool commit}) {
_pickSession++;
_pickEntry?.remove();
_pickEntry = null;
_pickFrame = null;
final anchor = _pickAnchor;
_pickAnchor = null;
if (!mounted) return;
if (!commit && anchor != null) _emitChanging(anchor);
_updatePicker(() => _isPicking = false);
}
void _setHex(String value) {
final hex = value.replaceAll("#", "");
if (hex.length != 6 && hex.length != 8) return;
final parsed = int.tryParse(hex, radix: 16);
if (parsed == null) return;
final color = hex.length == 6
? Color(0xFF000000 | parsed)
: Color(((parsed & 0xFF) << 24) | (parsed >> 8));
_emitChanging(HSVColor.fromColor(color));
}
void _syncHexValue() {
final argb = _hsv.toColor().toARGB32();
String component(int shift) =>
((argb >> shift) & 0xFF).toRadixString(16).padLeft(2, "0");
final display =
"#${component(16)}${component(8)}${component(0)}${widget.showAlpha ? component(24) : ""}"
.toUpperCase();
if (_hexController.text != display) _hexController.text = display;
}
void _syncChannelValues() {
void sync(TextEditingController controller, double value) {
final text = value.toStringAsFixed(3);
if (controller.text != text) controller.text = text;
}
sync(_hueController, _hsv.hue / 360);
sync(_saturationController, _hsv.saturation);
sync(_valueController, _hsv.value);
final argb = _hsv.toColor().toARGB32();
sync(_redController, ((argb >> 16) & 0xFF) / 255);
sync(_greenController, ((argb >> 8) & 0xFF) / 255);
sync(_blueController, (argb & 0xFF) / 255);
sync(_alphaController, _hsv.alpha);
}
void _setChannel(String text, HSVColor Function(double value) update) {
final value = double.tryParse(text);
if (value != null) _emitChanging(update(value.clamp(0.0, 1.0)));
}
void _setRgbChannel(String text, int shift) {
final value = double.tryParse(text);
if (value == null) return;
final argb = _hsv.toColor().toARGB32();
final channel = (value.clamp(0.0, 1.0) * 255).round();
final nextArgb = (argb & ~(0xFF << shift)) | (channel << shift);
_emitChanging(HSVColor.fromColor(Color(nextArgb)));
}
Widget _thumb() {
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFFFFFFF), width: 2),
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 2)],
),
);
}
}
class _ColorWheelPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.shortestSide / 2;
final bounds = Rect.fromCircle(center: center, radius: radius);
canvas.save();
canvas.clipPath(Path()..addOval(bounds));
canvas.drawRect(
bounds,
Paint()
..shader = SweepGradient(
colors: const [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
).createShader(bounds),
);
canvas.drawCircle(
center,
radius,
Paint()
..shader = RadialGradient(
colors: const [Color(0xFFFFFFFF), Color(0x00FFFFFF)],
).createShader(bounds),
);
canvas.restore();
canvas.drawCircle(
center,
radius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = const Color(0x66000000),
);
}
@override
bool shouldRepaint(covariant _ColorWheelPainter oldDelegate) => false;
}
class _PopoverPlacement {
const _PopoverPlacement({
required this.targetAnchor,
required this.followerAnchor,
required this.offset,
required this.maxWidth,
required this.maxHeight,
});
final Alignment targetAnchor;
final Alignment followerAnchor;
final Offset offset;
final double maxWidth;
final double maxHeight;
}
+116
View File
@@ -0,0 +1,116 @@
import "package:flutter/widgets.dart";
import "menu.dart";
const double _kContextMenuGap = 8.0;
const double _kContextMenuScreenPadding = 8.0;
const double _kContextMenuMinWidth = 192.0;
// shows a right-click style context menu at [globalPosition]. used to lean on
// shadcn's OverlayManager.showMenu; now its a plain flutter Overlay entry with
// a transparent tap-catcher to dismiss.
void showContextMenu({
required BuildContext context,
required Offset globalPosition,
required List<MenuItem> items,
required VoidCallback onDismissed,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
final overlayBox = overlay.context.findRenderObject() as RenderBox?;
final overlayPosition =
overlayBox?.globalToLocal(globalPosition) ?? globalPosition;
late OverlayEntry entry;
var closed = false;
void close() {
if (closed) return;
closed = true;
entry.remove();
onDismissed();
}
entry = OverlayEntry(
builder: (ctx) {
return Stack(
children: [
// tap anywhere outside to dismiss.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: close,
child: const SizedBox.expand(),
),
),
CustomSingleChildLayout(
delegate: _ContextMenuLayoutDelegate(position: overlayPosition),
child: MenuGroup(
direction: Axis.vertical,
subMenuOffset: const Offset(8, -4),
onDismissed: close,
builder: (ctx, children) => MenuPopup(children: children),
children: items,
),
),
],
);
},
);
overlay.insert(entry);
}
class _ContextMenuLayoutDelegate extends SingleChildLayoutDelegate {
const _ContextMenuLayoutDelegate({required this.position});
final Offset position;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final maxWidth = (constraints.maxWidth - _kContextMenuScreenPadding * 2)
.clamp(0.0, double.infinity);
final maxHeight = (constraints.maxHeight - _kContextMenuScreenPadding * 2)
.clamp(0.0, double.infinity);
return BoxConstraints(
minWidth: _kContextMenuMinWidth.clamp(0.0, maxWidth),
maxWidth: maxWidth,
maxHeight: maxHeight,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final minLeft = _kContextMenuScreenPadding;
final minTop = _kContextMenuScreenPadding;
final maxLeft = size.width - _kContextMenuScreenPadding - childSize.width;
final maxTop = size.height - _kContextMenuScreenPadding - childSize.height;
final preferredRight = position.dx + _kContextMenuGap;
final preferredLeft = position.dx - _kContextMenuGap - childSize.width;
final rightFits =
preferredRight + childSize.width <=
size.width - _kContextMenuScreenPadding;
final leftFits = preferredLeft >= _kContextMenuScreenPadding;
final opensRight = rightFits || !leftFits;
final rawLeft = opensRight ? preferredRight : preferredLeft;
final preferredBelow = position.dy;
final preferredAbove = position.dy - childSize.height;
final belowFits =
preferredBelow + childSize.height <=
size.height - _kContextMenuScreenPadding;
final aboveFits = preferredAbove >= _kContextMenuScreenPadding;
final opensBelow = belowFits || !aboveFits;
final rawTop = opensBelow ? preferredBelow : preferredAbove;
return Offset(
rawLeft.clamp(minLeft, maxLeft.clamp(minLeft, double.infinity)),
rawTop.clamp(minTop, maxTop.clamp(minTop, double.infinity)),
);
}
@override
bool shouldRelayout(_ContextMenuLayoutDelegate oldDelegate) {
return position != oldDelegate.position;
}
}
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
import "package:flutter/widgets.dart";
import "panel.dart";
import "theme/garage_theme.dart";
/// The reusable desktop editor frame.
///
/// Applications provide the content for each slot. The frame owns the
/// geometry and visual separation between the header, workspace, docks, and
/// footer, so an editor can keep its app-specific controls outside GarageUI.
class EditorShell extends StatelessWidget {
const EditorShell({
super.key,
required this.center,
this.header,
this.footer,
this.left,
this.right,
this.leftWidth = 0,
this.rightWidth = 0,
this.gap = 1,
this.backgroundColor,
});
final Widget center;
final Widget? header;
final Widget? footer;
final Widget? left;
final Widget? right;
final double leftWidth;
final double rightWidth;
final double gap;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final divider = theme.colorScheme.divider;
Widget dock(Widget child, double width) =>
SizedBox(width: width, child: child);
Widget separator() => ColoredBox(
color: divider,
child: SizedBox(width: gap),
);
final workspace = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (left != null && leftWidth > 0) ...[
dock(left!, leftWidth),
separator(),
],
Expanded(child: center),
if (right != null && rightWidth > 0) ...[
separator(),
dock(right!, rightWidth),
],
],
);
return ColoredBox(
color: backgroundColor ?? theme.colorScheme.background,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (header != null) header!,
Expanded(child: workspace),
if (footer != null) footer!,
],
),
);
}
}
/// Shared surface treatment for an editor's top and bottom chrome.
class ChromeBar extends StatelessWidget implements PreferredSizeWidget {
const ChromeBar({
super.key,
required this.child,
this.height,
this.padding = EdgeInsets.zero,
});
final Widget child;
/// Leave null to take [Density.chromeBarHeight], which is what keeps the
/// bar in step with the controls inside it. Was a hardcoded 30 that didnt
/// move with the tier at all, so at product density the bar came out
/// shorter than its own buttons.
final double? height;
final EdgeInsetsGeometry padding;
/// Without a BuildContext theres no density to ask, so this can only report
/// an explicit height. Nothing reads it today; the fallback matches the way
/// surface.dart's dividers handle the same problem.
@override
Size get preferredSize => Size.fromHeight(height ?? 0);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox(
height: height ?? theme.density.chromeBarHeight,
child: DecoratedBox(
decoration: BoxDecoration(color: theme.colorScheme.chrome),
child: Padding(padding: padding, child: child),
),
);
}
}
/// A dock surface with optional package-owned header and footer slots.
class DockPanel extends StatelessWidget {
const DockPanel({
super.key,
required this.child,
this.header,
this.footer,
this.padding = EdgeInsets.zero,
});
final Widget child;
final Widget? header;
final Widget? footer;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Panel(
borderRadius: 0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (header != null) header!,
Expanded(
child: Padding(padding: padding, child: child),
),
if (footer != null) footer!,
],
),
);
}
}
/// Garage editor layout: a main surface and two vertically stacked
/// sidebar surfaces separated by a draggable divider.
///
/// The shell owns the complete editor frame: header, workspace, footer, sizing,
/// hover highlighting, and the divider interaction. The application supplies
/// the header/footer content and the canvas, explorer, and properties widgets.
class GarageShell extends StatefulWidget {
const GarageShell({
super.key,
required this.main,
required this.sidebarTop,
required this.sidebarBottom,
this.header,
this.footer,
this.initialSidebarWidth = 320,
this.minPanelWidth = 100,
this.gap,
this.handleHeight = 200,
});
final Widget main;
final Widget sidebarTop;
final Widget sidebarBottom;
final Widget? header;
final Widget? footer;
final double initialSidebarWidth;
final double minPanelWidth;
/// Null uses the active theme's [ThemeData.panelGap], matching the original
/// Arcs & Angles Blender shell exactly.
final double? gap;
final double handleHeight;
@override
State<GarageShell> createState() => _GarageShellState();
}
class _GarageShellState extends State<GarageShell> {
late double _sidebarWidth = widget.initialSidebarWidth;
int? _hoveredPanel;
bool _dragging = false;
void _resizeSidebar(
DragUpdateDetails details,
double availableWidth,
double gap,
) {
final overhead = gap * 3;
final maxSidebarWidth = (availableWidth - overhead - widget.minPanelWidth)
.clamp(widget.minPanelWidth, double.infinity);
setState(() {
_sidebarWidth = (_sidebarWidth - details.delta.dx).clamp(
widget.minPanelWidth,
maxSidebarWidth,
);
});
}
Widget _panel(int id, Widget child) {
return MouseRegion(
onEnter: (_) {
if (_hoveredPanel != id) setState(() => _hoveredPanel = id);
},
onExit: (_) {
if (_hoveredPanel == id) setState(() => _hoveredPanel = null);
},
child: Panel(
active: _hoveredPanel == id,
clipBehavior: Clip.hardEdge,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
final gap = widget.gap ?? theme.panelGap;
final workspace = ColoredBox(
color: scheme.chrome,
child: LayoutBuilder(
builder: (context, constraints) {
final sidebarWidth = _sidebarWidth;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: EdgeInsets.only(left: gap),
child: _panel(0, widget.main),
),
),
SizedBox(
width: gap,
child: Align(
alignment: Alignment.center,
child: SizedBox(
height: widget.handleHeight,
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: GestureDetector(
// pane splitter, not a control - keep it out of the
// semantics tree so it doesn't read as a bogus scrollable
excludeFromSemantics: true,
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) =>
setState(() => _dragging = true),
onHorizontalDragUpdate: (details) =>
_resizeSidebar(details, constraints.maxWidth, gap),
onHorizontalDragEnd: (_) =>
setState(() => _dragging = false),
onHorizontalDragCancel: () =>
setState(() => _dragging = false),
child: SizedBox(
width: gap,
child: Center(
child: _dragging
? Container(
width: 1.5,
color: scheme.mutedForeground,
)
: null,
),
),
),
),
),
),
),
Padding(
padding: EdgeInsets.only(right: gap),
child: SizedBox(
width: sidebarWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _panel(1, widget.sidebarTop)),
SizedBox(height: gap),
Expanded(child: _panel(2, widget.sidebarBottom)),
],
),
),
),
],
);
},
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.header != null) widget.header!,
Expanded(child: workspace),
if (widget.footer != null) widget.footer!,
],
);
}
}
+145
View File
@@ -0,0 +1,145 @@
import "package:flutter/widgets.dart";
// our own theme now - we only need it for the icon size theme (iconTheme.small etc).
import "package:garage_ui/theme/garage_theme.dart";
// Hand rolled replacements for the shadcn Widget/Icon extension methods.
// These get used 350+ times across the app so behaviour has to match the
// original exactly — same SizedBox/Padding/Center/Expanded wrappers, same
// icon sizes pulled from the theme.
/// Layout helpers that shadcn hangs off every Widget.
extension WidgetExtension on Widget {
/// Wraps this widget in a [SizedBox]. If it's already a SizedBox we merge
/// the dims instead of double wrapping (matches shadcn).
Widget sized({double? width, double? height, double? size}) {
if (this is SizedBox) {
return SizedBox(
width: width ?? size ?? (this as SizedBox).width,
height: height ?? size ?? (this as SizedBox).height,
child: (this as SizedBox).child,
);
}
return SizedBox(width: width ?? size, height: height ?? size, child: this);
}
/// Wraps this widget in [Padding]. You can pass individual edges, the
/// combined horizontal/vertical, a uniform `all`, or a raw EdgeInsets via
/// `padding` (which wins over everything else).
Widget withPadding({
double? top,
double? bottom,
double? left,
double? right,
double? horizontal,
double? vertical,
double? all,
EdgeInsetsGeometry? padding,
}) {
assert(() {
if (all != null) {
if (top != null ||
bottom != null ||
left != null ||
right != null ||
horizontal != null ||
vertical != null) {
throw FlutterError(
"All padding properties cannot be used with other padding properties.",
);
}
} else if (horizontal != null) {
if (left != null || right != null) {
throw FlutterError(
"Horizontal padding cannot be used with left or right padding.",
);
}
} else if (vertical != null) {
if (top != null || bottom != null) {
throw FlutterError(
"Vertical padding cannot be used with top or bottom padding.",
);
}
}
return true;
}());
var edgeInsets = EdgeInsets.only(
top: top ?? vertical ?? all ?? 0,
bottom: bottom ?? vertical ?? all ?? 0,
left: left ?? horizontal ?? all ?? 0,
right: right ?? horizontal ?? all ?? 0,
);
return Padding(padding: padding ?? edgeInsets, child: this);
}
/// Centers the widget in its parent.
Widget center({Key? key}) {
return Center(key: key, child: this);
}
/// Makes this widget [Expanded] inside a Row/Column.
Widget expanded({int flex = 1}) {
return Expanded(flex: flex, child: this);
}
}
/// Icon size helpers. shadcn returns a WrappedIcon that reads the size off the
/// theme (theme.iconTheme.small etc) so it respects AdaptiveScaling — we do the
/// exact same thing rather than hardcoding, otherwise scaled builds drift.
extension IconExtension on Widget {
/// small icon — 16px at scale 1.
Widget get iconSmall {
return _GarageWrappedIcon(pick: (t) => t.iconTheme.small, child: this);
}
/// medium icon — 20px at scale 1.
Widget get iconMedium {
return _GarageWrappedIcon(pick: (t) => t.iconTheme.medium, child: this);
}
/// large icon — 24px at scale 1.
Widget get iconLarge {
return _GarageWrappedIcon(pick: (t) => t.iconTheme.large, child: this);
}
}
typedef _IconThemePicker = IconThemeData Function(ThemeData theme);
// mirrors shadcn's WrappedIcon — but merges the SIZE ONLY.
//
// The tier's IconThemeData carries the global scheme foreground alongside its
// size, and merging that whole thing overrode whatever IconTheme already
// enclosed the icon. Inside a Button that meant the button's own variant
// colour lost to the global one: a primary button hands its leading icon
// primaryForeground, and `.iconSmall` put scheme.foreground back over the top.
// On a dark scheme both of those are near-white while the primary FILL is too,
// so the icon painted white on white and simply vanished.
//
// Size is the only thing these helpers are for. Colour is the enclosing
// theme's business - a button's, or the app's for an icon standing on its own,
// which is the same value the tier was supplying anyway.
class _GarageWrappedIcon extends StatelessWidget {
final _IconThemePicker pick;
final Widget child;
const _GarageWrappedIcon({required this.pick, required this.child});
@override
Widget build(BuildContext context) {
double? size;
try {
size = pick(GarageTheme.of(context)).size;
} catch (e, st) {
// shouldnt happen (there's always a Theme above us) but if it does we
// still want a visible icon rather than a crash. log it so it's debuggable.
debugPrint("ana iconTheme lookup failed: $e");
debugPrintStack(stackTrace: st);
size = 16;
}
return IconTheme.merge(
data: IconThemeData(size: size),
child: child,
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// How long a field takes to admit theres something wrong with it, and how
/// long the row takes to make room for saying so. One number for both: the
/// outline and the line under it are one event and shouldnt arrive at
/// different times.
const Duration kFieldErrorDuration = Duration(milliseconds: 180);
const Curve kFieldErrorCurve = Curves.easeInOutCubic;
/// "This one was rejected", published over a control rather than set on it.
///
/// A row knows the value is wrong. What the row is HOLDING could be anything -
/// a text box, a select, a group of them - so the row cant reach in and colour
/// it. It says so here instead, and every control that draws itself a border
/// reads this on the way past.
///
/// Only the border moves. A field whose fill goes red reads as a state the
/// thing is permanently in; a red outline reads as a correction, which is what
/// this is - and it goes back to normal the moment the value does.
class FieldErrorScope extends InheritedWidget {
const FieldErrorScope({
super.key,
required this.invalid,
required super.child,
});
final bool invalid;
static bool of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<FieldErrorScope>()?.invalid ??
false;
@override
bool updateShouldNotify(FieldErrorScope old) => old.invalid != invalid;
}
/// The destructive outline, faded in OVER whatever border is allready there.
///
/// Over rather than instead of, deliberately. A control's fill changes the
/// instant you hover it - button.dart says so in as many words, and a
/// TextField matches it - so rebuilding the decoration with an animated
/// colour in it would have dragged the fill into the transition too and made
/// every hover ease. This sits on top and is the only thing that moves.
///
/// It also means a control with no border at all - a ghost select, say - gets
/// one here for nothing, which is what "the border goes red" needs in order
/// to mean anything.
class FieldErrorOutline extends StatelessWidget {
const FieldErrorOutline({
super.key,
required this.invalid,
required this.borderRadius,
required this.child,
});
final bool invalid;
final BorderRadiusGeometry borderRadius;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return Stack(
children: [
child,
// Positioned.fill, so it takes the child's size rather than joining in
// deciding what that size is.
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: invalid ? 1.0 : 0.0,
duration: kFieldErrorDuration,
curve: kFieldErrorCurve,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
border: Border.all(
color: theme.colorScheme.destructive,
width: theme.density.controlBorderWidth,
),
),
),
),
),
),
],
);
}
}
+52
View File
@@ -0,0 +1,52 @@
// Public Garage UI surface.
//
// Two influences, and theyre not the same kind of influence. The API surface
// follows shadcn_flutter - the variant names, the ColourScheme slots, the
// dot-constructor shape - because that vocabulary is good and the app was
// built against it. The way things actually render follows Blender: flat
// chrome, bordered panels, dense controls, no elevation.
//
// So this is NOT a pixel for pixel restyle of shadcn. It reads as a Garage
// app, not a shadcn one - the geometry, the density and the whole chrome
// layer are ours. If youre porting a shadcn snippet, expect the call to
// compile and the result to look different, on purpose.
//
// shadcn_flutter itself is long gone as a dependency. Import this, or the
// individual files.
export "app.dart";
export "button.dart";
export "select.dart";
export "text_field.dart";
export "input_mask.dart";
export "menu.dart";
export "surface.dart";
export "selection_controls.dart";
// overlay's own Popover/PopoverController subsystem was dropped (unused - the
// app's only PopoverController is menu.dart's, via menu_bar).
export "overlay.dart";
export "pane_overlay.dart";
export "toast.dart";
export "navigation.dart";
export "extensions.dart";
export "misc.dart";
export "color_input.dart";
export "date_input.dart";
export "panel.dart";
export "editor_chrome.dart";
export "context_menu.dart";
export "properties.dart";
export "semantics_scope.dart";
export "scroll_edge_fade.dart";
export "scrollbar.dart";
export "settings_list.dart";
export "field_error.dart";
export "tab_view.dart";
export "sheet.dart";
export "app_menu.dart";
export "app_menu_notifier.dart";
export "theme.dart";
export "app_frame_capture.dart";
export "package:flutter_lucide/flutter_lucide.dart";
+183
View File
@@ -0,0 +1,183 @@
import "package:flutter/services.dart";
/// What a mask slot will accept.
///
/// Anything in a mask that isnt one of these is a literal, inserted for you as
/// you type past it and never something you have to enter yourself.
enum MaskSlot {
/// `#` — 0-9.
digit("#"),
/// `A` — a letter, either case.
letter("A"),
/// `*` — a letter or a digit.
alphanumeric("*");
const MaskSlot(this.token);
final String token;
bool accepts(String ch) => switch (this) {
MaskSlot.digit => _isDigit(ch),
MaskSlot.letter => _isLetter(ch),
MaskSlot.alphanumeric => _isDigit(ch) || _isLetter(ch),
};
static MaskSlot? of(String ch) {
for (final s in MaskSlot.values) {
if (s.token == ch) return s;
}
return null;
}
}
bool _isDigit(String ch) {
final c = ch.codeUnitAt(0);
return c >= 0x30 && c <= 0x39;
}
bool _isLetter(String ch) {
final c = ch.codeUnitAt(0);
return (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A);
}
/// Types a value into a fixed shape as you go — `### ###-####`, `AA# #AA`.
///
/// `#` takes a digit, `A` a letter, `*` either; everything else is a literal
/// that appears on its own once you've typed up to it. Input that doesnt fit a
/// slot is dropped rather than rejected wholesale, so a pasted
/// `(555) 123-4567` lands in `### ###-####` as `555 123-4567` instead of
/// nothing.
///
/// It works by reducing whatever the field now holds back to its *slot*
/// characters and re-laying the mask over them. That means one code path for
/// typing, pasting, deleting and dragging a selection, rather than four that
/// each have to agree — the failure mode of hand-rolled masks is usually that
/// they only got typing right.
///
/// The caveat that comes with any mask: the field's value is the FORMATTED
/// string. If what you store is the digits, strip it on the way out (see
/// [unmask]) rather than assuming the two are the same.
class MaskedTextInputFormatter extends TextInputFormatter {
MaskedTextInputFormatter(this.mask)
: assert(mask.length > 0, "an empty mask accepts nothing"),
_slots = [for (final ch in mask.split("")) MaskSlot.of(ch)] {
assert(
_slots.any((s) => s != null),
"a mask with no #, A or * is all literal - nothing could be typed",
);
}
/// e.g. `### ###-####`.
final String mask;
/// One entry per mask character: the slot it accepts, or null for a literal.
final List<MaskSlot?> _slots;
/// How many characters this mask can hold once full.
int get slotCount => _slots.where((s) => s != null).length;
/// The slot characters of [text], with the mask's own literals taken out.
///
/// The single reduction the whole formatter runs on: typing, pasting,
/// deleting and dragging a selection all come through here, so they cant
/// disagree with each other.
///
/// It walks the mask and the text together rather than filtering the text on
/// its own, which is what makes a literal that LOOKS like data behave - the
/// leading `1` of `1-###-####` is consumed as the literal it is instead of
/// being returned as the first digit somebody typed. Text that doesnt line
/// up (a pasted `(555) 123-4567`) just has the junk skipped.
String unmask(String text) {
final out = StringBuffer();
var i = 0; // into the mask
var t = 0; // into the text
while (i < _slots.length && t < text.length) {
final slot = _slots[i];
if (slot == null) {
// only step over the text's copy of this literal if its actually there
if (text[t] == mask[i]) t++;
i++;
continue;
}
if (slot.accepts(text[t])) {
out.write(text[t]);
i++;
}
t++;
}
return out.toString();
}
/// Lay the mask over [raw], stopping when either runs out.
///
/// Trailing literals are left off: a half typed `555` in `### ###-####` is
/// `555`, not `555 ` with a space you didnt ask for and cant delete.
String apply(String raw) {
final out = StringBuffer();
var r = 0;
for (var i = 0; i < _slots.length && r < raw.length; i++) {
if (_slots[i] == null) {
out.write(mask[i]);
continue;
}
out.write(raw[r]);
r++;
}
return out.toString();
}
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
// reduce to slot characters, ignoring where the literals were - this is
// what makes paste and drag-select behave the same as typing.
var raw = unmask(newValue.text);
// Deleting a literal has to delete something, or backspace looks frozen:
// "555 1" backspace kills the space, the slot characters are unchanged,
// and re-applying puts the space straight back. So when a delete didnt
// change the slot characters, take the one before the caret too.
final deleted = newValue.text.length < oldValue.text.length;
if (deleted && raw == unmask(oldValue.text) && raw.isNotEmpty) {
final upTo = unmask(
newValue.text.substring(
0,
newValue.selection.baseOffset.clamp(0, newValue.text.length),
),
).length;
final cut = upTo > 0 ? upTo - 1 : 0;
raw = raw.substring(0, cut) + raw.substring(cut + 1);
}
final formatted = apply(raw);
// put the caret after the same number of slot characters it was after
// before, which is the only position that survives literals moving.
final before = unmask(
newValue.text.substring(
0,
newValue.selection.baseOffset.clamp(0, newValue.text.length),
),
).length;
var offset = formatted.length;
var seen = 0;
for (var i = 0; i < formatted.length; i++) {
if (seen == before) {
offset = i;
break;
}
if (_slots[i] != null) seen++;
}
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: offset.clamp(0, formatted.length),
),
);
}
}
File diff suppressed because it is too large Load Diff
+257
View File
@@ -0,0 +1,257 @@
import "dart:math" as math;
import "package:flutter/services.dart" show LogicalKeyboardKey;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// grab bag of the smaller shadcn widgets the app still reaches for. nothing
// fancy - just enough to match how they were used.
// shadcn's Scaffold gave the page a background + structure. the app only ever
// hands it a child, so thats all we do: fill with the theme background.
class Scaffold extends StatelessWidget {
const Scaffold({
super.key,
required this.child,
this.headers = const [],
this.footers = const [],
this.backgroundColor,
});
final Widget child;
final List<Widget> headers;
final List<Widget> footers;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final body = ColoredBox(
color: backgroundColor ?? theme.colorScheme.background,
child: child,
);
if (headers.isEmpty && footers.isEmpty) return body;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...headers,
Expanded(child: body),
...footers,
],
);
}
}
/// A small indeterminate spinner with no Material dependency.
class CircularProgressIndicator extends StatefulWidget {
const CircularProgressIndicator({
super.key,
this.color,
this.strokeWidth = 4.0,
this.size = 18.0,
});
final Color? color;
final double strokeWidth;
final double size;
@override
State<CircularProgressIndicator> createState() =>
_CircularProgressIndicatorState();
}
class _CircularProgressIndicatorState extends State<CircularProgressIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => CustomPaint(
painter: _CircularProgressPainter(
progress: _controller.value,
color: widget.color ?? theme.colorScheme.primary,
strokeWidth: widget.strokeWidth,
),
),
),
);
}
}
class _CircularProgressPainter extends CustomPainter {
const _CircularProgressPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
final double progress;
final Color color;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final inset = strokeWidth / 2;
final rect =
Offset(inset, inset) & Size.square(size.shortestSide - strokeWidth);
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = strokeWidth;
canvas.drawArc(
rect,
-math.pi / 2 + progress * math.pi * 2,
math.pi * 1.35,
false,
paint,
);
}
@override
bool shouldRepaint(_CircularProgressPainter oldDelegate) =>
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
// renders a keyboard shortcut as little key caps. `keys` is usually a list of
// LogicalKeyboardKey but we tolerate anything (some call sites pass strings).
class KeyboardDisplay extends StatelessWidget {
const KeyboardDisplay({super.key, required this.keys, this.spacing = 4});
final List<dynamic> keys;
final double spacing;
String _label(dynamic k) {
if (k is LogicalKeyboardKey) {
final l = k.keyLabel;
return l.isNotEmpty ? l : k.debugName ?? "?";
}
return "$k";
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final caps = <Widget>[];
for (var i = 0; i < keys.length; i++) {
if (i > 0) caps.add(SizedBox(width: spacing * scaling));
caps.add(
Container(
padding: EdgeInsets.symmetric(
horizontal: 5 * scaling,
vertical: 2 * scaling,
),
decoration: BoxDecoration(
color: theme.colorScheme.muted,
borderRadius: theme.borderRadiusSm,
border: Border.all(color: theme.colorScheme.border, width: scaling),
),
child: DefaultTextStyle.merge(
style: TextStyle(color: theme.colorScheme.mutedForeground),
child: Text(_label(keys[i])),
),
),
);
}
return Row(mainAxisSize: MainAxisSize.min, children: caps);
}
}
// shows [hoverBuilder]'s content floating near the child while hovered. shadcn's
// HoverCard, trimmed to a plain overlay follower.
class HoverCard extends StatefulWidget {
const HoverCard({
super.key,
required this.child,
required this.hoverBuilder,
this.anchorAlignment = Alignment.bottomLeft,
this.cardAlignment = Alignment.topLeft,
this.waitDuration = const Duration(milliseconds: 300),
});
final Widget child;
final WidgetBuilder hoverBuilder;
final Alignment anchorAlignment;
final Alignment cardAlignment;
final Duration waitDuration;
@override
State<HoverCard> createState() => _HoverCardState();
}
class _HoverCardState extends State<HoverCard> {
final LayerLink _link = LayerLink();
OverlayEntry? _entry;
void _show() {
if (_entry != null) return;
final overlay = Overlay.of(context);
_entry = OverlayEntry(
// needs an explicit origin: a Positioned with all-null coords is a
// non-positioned overlay child and gets laid out with TIGHT full-screen
// constraints, which blows any sized() card up to the whole screen. the
// follower does the real placement via the link regardless.
builder: (ctx) => Positioned(
left: 0,
top: 0,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: widget.anchorAlignment,
followerAnchor: widget.cardAlignment,
child: MouseRegion(
onExit: (_) => _hide(),
child: widget.hoverBuilder(ctx),
),
),
),
);
overlay.insert(_entry!);
}
void _hide() {
_entry?.remove();
_entry = null;
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _link,
child: MouseRegion(
onEnter: (_) => _show(),
onExit: (_) {
// give the pointer a beat to reach the card before we yank it.
Future.delayed(const Duration(milliseconds: 60), () {
if (mounted && _entry != null) _hide();
});
},
child: widget.child,
),
);
}
}
+600
View File
@@ -0,0 +1,600 @@
// GarageUI — navigation groups + progress indicators.
//
// used to be a near-verbatim shadcn fork with the whole sliver/Data/overflow
// machinery bolted on. the app only ever leans on a handful of these, so this
// is the trimmed, standalone version: plain flutter widgets + GarageTheme. no
// Data lookups, no NavigationControlData plumbing, no sliver headers.
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
import "package:garage_ui/button.dart";
/// Determines when labels are shown in navigation items.
enum NavigationLabelType {
/// No labels displayed.
none,
/// Labels shown only for selected items.
selected,
/// Labels always shown for all items.
all,
/// Labels shown as tooltips on hover.
tooltip,
/// Labels shown when navigation is expanded.
expanded,
}
/// Position of navigation item labels relative to the children.
enum NavigationLabelPosition {
/// Label before the items (left in LTR).
start,
/// Label after the items (right in LTR).
end,
/// Label above the items.
top,
/// Label below the items.
bottom,
}
/// Visual divider between navigation items.
///
/// Renders a thin horizontal line separator. The old version flipped direction
/// off inherited nav data and could emit a sliver — we only ever use it inside
/// vertical rails, so it's just a padded rule now.
class NavigationDivider extends StatelessWidget {
/// Optional thickness of the divider line.
final double? thickness;
/// Optional color for the divider.
final Color? color;
/// Creates a navigation divider.
const NavigationDivider({super.key, this.thickness, this.color});
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final line = Container(
height: thickness ?? (1 * scaling),
color: color ?? theme.colorScheme.divider,
);
return Padding(
padding: EdgeInsets.symmetric(vertical: theme.density.containerGap * 0.5),
child: line,
);
}
}
/// Groups navigation children under a label header.
///
/// Vertical column only — a padded label, a gap, then the children stretched to
/// fill. Label can sit before or after the items via [labelPosition].
class NavigationGroup extends StatelessWidget {
/// Label widget shown for the group.
final Widget label;
/// The child items within this group.
final List<Widget> children;
/// Position of the label relative to the children.
final NavigationLabelPosition labelPosition;
/// Alignment of the label content.
final AlignmentGeometry? labelAlignment;
/// Padding around the label.
final EdgeInsetsGeometry? labelPadding;
/// Creates a new navigation group.
const NavigationGroup({
super.key,
required this.label,
this.children = const [],
this.labelPosition = NavigationLabelPosition.top,
this.labelAlignment,
this.labelPadding,
});
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
// label sits at half the container padding, items get a full gap under it.
final contentPadding = theme.density.containerPadding;
final gap = theme.density.containerGap;
final paddedLabel = Container(
alignment: labelAlignment ?? Alignment.center,
padding:
labelPadding ??
EdgeInsets.symmetric(horizontal: contentPadding * 0.5),
child: DefaultTextStyle.merge(
textAlign: TextAlign.center,
maxLines: 1,
child: label,
),
);
final items = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
final labelFirst =
labelPosition == NavigationLabelPosition.top ||
labelPosition == NavigationLabelPosition.start;
final ordered = labelFirst
? [paddedLabel, SizedBox(height: gap), items]
: [items, SizedBox(height: gap), paddedLabel];
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: ordered,
);
}
}
/// Duration of one indeterminate sweep cycle.
const int _kIndeterminateLinearDuration = 1800;
/// A linear progress bar with determinate + indeterminate modes.
///
/// Determinate ([value] != null) fills left-to-right and eases toward each new
/// value. Indeterminate ([value] == null) runs two overlapping segments across
/// the track forever. Sparks/rtl/theme-lookup extras from the shadcn original
/// were dropped — nothing in the app used them.
class LinearProgressIndicator extends StatefulWidget {
// timing curves for the twin-line indeterminate motion, lifted straight from
// material so the sweep feels the same.
static const Curve _line1Head = Interval(
0.0,
750.0 / _kIndeterminateLinearDuration,
curve: Cubic(0.2, 0.0, 0.8, 1.0),
);
static const Curve _line1Tail = Interval(
333.0 / _kIndeterminateLinearDuration,
(333.0 + 750.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.4, 0.0, 1.0, 1.0),
);
static const Curve _line2Head = Interval(
1000.0 / _kIndeterminateLinearDuration,
(1000.0 + 567.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.0, 0.0, 0.65, 1.0),
);
static const Curve _line2Tail = Interval(
1267.0 / _kIndeterminateLinearDuration,
(1267.0 + 533.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.10, 0.0, 0.45, 1.0),
);
/// Progress between 0.0 and 1.0. Null => indeterminate.
final double? value;
/// Background color of the track.
final Color? backgroundColor;
/// Minimum height of the bar.
final double? minHeight;
/// Primary color of the fill.
final Color? color;
/// Border radius of the container.
final BorderRadiusGeometry? borderRadius;
/// Creates a [LinearProgressIndicator].
const LinearProgressIndicator({
super.key,
this.value,
this.backgroundColor,
this.minHeight,
this.color,
this.borderRadius,
});
@override
State<LinearProgressIndicator> createState() =>
_LinearProgressIndicatorState();
}
class _LinearProgressIndicatorState extends State<LinearProgressIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: _kIndeterminateLinearDuration),
);
_syncController();
}
@override
void didUpdateWidget(covariant LinearProgressIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
// only the indeterminate mode wants a ticking controller. flip it on/off
// when the value nullability changes so we're not spinning for nothing.
if ((oldWidget.value == null) != (widget.value == null)) {
_syncController();
}
}
void _syncController() {
if (widget.value == null) {
_controller.repeat();
} else {
_controller.stop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final colorValue = widget.color ?? theme.colorScheme.primary;
final backgroundColorValue =
widget.backgroundColor ?? colorValue.scaleAlpha(0.2);
final minHeightValue = widget.minHeight ?? theme.scaling * 2;
final borderRadiusValue = widget.borderRadius ?? BorderRadius.zero;
Widget bar;
if (widget.value != null) {
// determinate — ease toward the new fill whenever value changes
bar = TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: widget.value!.clamp(0.0, 1.0)),
duration: kDefaultDuration,
curve: Curves.easeInOut,
builder: (context, v, _) {
return CustomPaint(
painter: _LinearProgressPainter(
end: v,
color: colorValue,
backgroundColor: backgroundColorValue,
),
);
},
);
} else {
// indeterminate — two overlapping segments chasing across the track
bar = AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final t = _controller.value;
return CustomPaint(
painter: _LinearProgressPainter(
start: LinearProgressIndicator._line1Tail.transform(t),
end: LinearProgressIndicator._line1Head.transform(t),
start2: LinearProgressIndicator._line2Tail.transform(t),
end2: LinearProgressIndicator._line2Head.transform(t),
color: colorValue,
backgroundColor: backgroundColorValue,
),
);
},
);
}
return RepaintBoundary(
child: SizedBox(
height: minHeightValue,
child: ClipRRect(borderRadius: borderRadiusValue, child: bar),
),
);
}
}
class _LinearProgressPainter extends CustomPainter {
final double start;
final double end;
final double? start2; // for the second indeterminate segment
final double? end2;
final Color color;
final Color backgroundColor;
_LinearProgressPainter({
this.start = 0,
required this.end,
this.start2,
this.end2,
required this.color,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
var start = this.start;
var end = this.end;
var start2 = this.start2;
var end2 = this.end2;
// nan sneaks in from curve maths at the extremes — clamp it to nothing
if (start.isNaN) start = 0;
if (end.isNaN) end = 0;
if (start2 != null && start2.isNaN) start2 = 0;
if (end2 != null && end2.isNaN) end2 = 0;
final paint = Paint()..style = PaintingStyle.fill;
paint.color = backgroundColor;
canvas.drawRRect(
RRect.fromLTRBR(
0,
0,
size.width,
size.height,
Radius.circular(size.height / 2),
),
paint,
);
paint.color = color;
canvas.drawRect(
Rect.fromLTWH(
size.width * start,
0,
size.width * (end - start),
size.height,
),
paint,
);
if (start2 != null && end2 != null) {
canvas.drawRect(
Rect.fromLTWH(
size.width * start2,
0,
size.width * (end2 - start2),
size.height,
),
paint,
);
}
}
@override
bool shouldRepaint(covariant _LinearProgressPainter old) {
return old.start != start ||
old.end != end ||
old.start2 != start2 ||
old.end2 != end2 ||
old.color != color ||
old.backgroundColor != backgroundColor;
}
}
// ---------------------------------------------------------------------------
// NavigationRail — the little sidebar the settings panels use. collapses to
// icons, expands to icon + label. (shadcn had a much bigger rail; this is the
// slice the app actually drives.)
// ---------------------------------------------------------------------------
enum NavigationRailAlignment { start, center, end }
// carries the rail's expanded state + label side down to the items.
class _NavRailScope extends InheritedWidget {
const _NavRailScope({
required this.expanded,
required this.labelPosition,
required super.child,
});
final bool expanded;
final NavigationLabelPosition labelPosition;
static _NavRailScope? of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_NavRailScope>();
@override
bool updateShouldNotify(_NavRailScope old) =>
old.expanded != expanded || old.labelPosition != labelPosition;
}
class NavigationRail extends StatelessWidget {
const NavigationRail({
super.key,
this.children = const [],
this.header = const [],
this.footer = const [],
this.backgroundColor,
this.labelType = NavigationLabelType.selected,
this.labelPosition = NavigationLabelPosition.bottom,
this.alignment = NavigationRailAlignment.start,
this.expanded = false,
this.expandedSize = 150,
this.collapsedSize = 50,
this.padding,
});
final List<Widget> children;
final List<Widget> header;
final List<Widget> footer;
final Color? backgroundColor;
final NavigationLabelType labelType;
final NavigationLabelPosition labelPosition;
final NavigationRailAlignment alignment;
final bool expanded;
final double expandedSize;
final double collapsedSize;
final EdgeInsetsGeometry? padding;
MainAxisAlignment get _mainAxis => switch (alignment) {
NavigationRailAlignment.start => MainAxisAlignment.start,
NavigationRailAlignment.center => MainAxisAlignment.center,
NavigationRailAlignment.end => MainAxisAlignment.end,
};
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return _NavRailScope(
expanded: expanded,
labelPosition: labelPosition,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: (expanded ? expandedSize : collapsedSize) * 1.0,
color: backgroundColor ?? theme.colorScheme.secondary,
padding: padding ?? EdgeInsets.all(6 * theme.scaling),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: _mainAxis,
children: [
...header,
...children,
if (footer.isNotEmpty) const Spacer(),
...footer,
],
),
),
);
}
}
class NavigationItem extends StatelessWidget {
const NavigationItem({
super.key,
required this.child,
this.label,
this.selected = false,
this.onChanged,
this.selectedStyle,
});
final Widget child;
final Widget? label;
final bool selected;
final ValueChanged<bool>? onChanged;
final ButtonStyle? selectedStyle;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final expanded = _NavRailScope.of(context)?.expanded ?? false;
final fg = selected
? theme.colorScheme.primaryForeground
: theme.colorScheme.foreground;
Widget row = Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
IconTheme.merge(
data: IconThemeData(color: fg, size: theme.iconTheme.medium.size),
child: child,
),
if (expanded && label != null) ...[
SizedBox(width: theme.density.containerGap),
DefaultTextStyle.merge(
style: TextStyle(color: fg),
child: label!,
),
],
],
);
return Padding(
padding: EdgeInsets.symmetric(vertical: 2 * theme.scaling),
child: MergeSemantics(
child: Semantics(
container: true,
selected: selected,
inMutuallyExclusiveGroup: true,
onTap: () => onChanged?.call(!selected),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: () => onChanged?.call(!selected),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8 * theme.scaling,
vertical: 8 * theme.scaling,
),
decoration: BoxDecoration(
color: selected
? theme.colorScheme.primary
: const Color(0x00000000),
borderRadius: theme.borderRadiusMd,
),
child: row,
),
),
),
),
);
}
}
// a free-form rail row (custom leading + optional title + tap). used for the
// collapse toggle.
class NavigationSlot extends StatelessWidget {
const NavigationSlot({
super.key,
this.title,
this.leading,
this.onPressed,
this.alignment = Alignment.centerLeft,
});
final Widget? title;
final Widget? leading;
final VoidCallback? onPressed;
final Alignment alignment;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final expanded = _NavRailScope.of(context)?.expanded ?? false;
final isCenter = alignment.x == 0 && !expanded;
Widget row = Row(
mainAxisAlignment: isCenter
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
if (leading != null) leading!,
if (expanded && title != null) ...[
SizedBox(width: theme.density.containerGap),
title!,
],
],
);
return Padding(
padding: EdgeInsets.symmetric(vertical: 2 * theme.scaling),
child: MergeSemantics(
child: Semantics(
container: true,
button: true,
onTap: onPressed,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: onPressed,
child: row,
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import "dart:ui" show ImageFilter;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// The entrance a Garage pane dialog makes: blurred backdrop, dimmed scrim,
/// fade and scale in, width clamped to the viewport.
///
/// This lived twice in Arcs & Angles - once for the titled pane and once for
/// the Blender-language one - identical apart from the shell widget wrapped
/// around the content. The presentation is the generic half, so it lives here
/// and [builder] supplies the shell.
///
/// [builder] is handed an `onClose` that pops the route, or null when
/// [dismissible] is false, so the shell can wire its own close affordance.
/// Content pops with its own result the usual way.
Future<T?> showPaneOverlay<T>({
required BuildContext context,
required Widget Function(BuildContext context, VoidCallback? onClose) builder,
double maxWidth = 450,
bool dismissible = true,
String? barrierLabel,
Duration duration = const Duration(milliseconds: 180),
double blurSigma = 3,
double scrimOpacity = 0.35,
double enterScale = 0.93,
double viewportInset = 24,
}) {
// read before the route is pushed so it reflects the theme at the call site
// rather than whatever sits above the navigator.
final scrim = GarageTheme.of(context).colorScheme.background;
return showGeneralDialog<T>(
context: context,
barrierDismissible: false,
barrierColor: const Color(0x00000000),
barrierLabel: barrierLabel,
transitionDuration: duration,
pageBuilder: (ctx, anim, secondaryAnim) => const SizedBox.shrink(),
transitionBuilder: (ctx, anim, secondaryAnim, _) {
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
final scale = Tween<double>(
begin: enterScale,
end: 1.0,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final screenW = MediaQuery.sizeOf(ctx).width;
final maxW = (screenW - viewportInset).clamp(0.0, maxWidth);
void close() => Navigator.of(ctx).pop();
return Stack(
children: [
Positioned.fill(
child: FadeTransition(
opacity: fade,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: GestureDetector(
// a dismiss scrim, not a control - Escape and the dialog's
// own close button are the reachable ways out. Left in the
// tree it reads as a full-screen unnamed button.
excludeFromSemantics: true,
onTap: dismissible ? close : null,
child: ColoredBox(
color: scrim.withValues(alpha: scrimOpacity),
),
),
),
),
),
Center(
child: FadeTransition(
opacity: fade,
child: ScaleTransition(
scale: scale,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: maxW, maxWidth: maxW),
child: builder(ctx, dismissible ? close : null),
),
),
),
),
],
);
},
);
}
+91
View File
@@ -0,0 +1,91 @@
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// A chrome panel - the bordered, rounded surface the shell docks things in.
// Fills with the scheme's panel colour and lights its border with the
// highlighted colour when active.
//
// Two modes:
// - standalone: leave [active] null and it tracks its own hover.
// - controlled: pass [active] and the parent drives the lit state (used by the
// shell so it can keep the panels mutually exclusive - only one lit at once,
// which also dodges flutter dropping an onExit during a fast mouse move and
// leaving a panel stuck on).
class Panel extends StatefulWidget {
const Panel({
super.key,
required this.child,
this.active,
this.borderRadius,
this.borderWidth = 1.15,
this.clip = true,
this.clipBehavior = Clip.antiAlias,
});
final Widget child;
// null = self-track hover; non-null = parent controls the lit state.
final bool? active;
// null = use the theme's panelRadius.
final double? borderRadius;
final double borderWidth;
// clip the child to the rounded corners.
final bool clip;
final Clip clipBehavior;
@override
State<Panel> createState() => _PanelState();
}
class _PanelState extends State<Panel> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final controlled = widget.active != null;
final lit = controlled ? widget.active! : _hovered;
final radius = BorderRadius.circular(
widget.borderRadius ?? theme.panelRadius,
);
Widget content = widget.child;
if (widget.clip) {
final clipRadius = BorderRadius.circular(
((widget.borderRadius ?? theme.panelRadius) - 1)
.clamp(0, double.infinity)
.toDouble(),
);
content = ClipRRect(
borderRadius: clipRadius,
clipBehavior: widget.clipBehavior,
child: content,
);
}
Widget box = Container(
decoration: BoxDecoration(
color: cs.background,
borderRadius: radius,
border: Border.all(
color: lit ? cs.panelBorderHighlighted : cs.panelBorder,
width: widget.borderWidth,
),
),
child: content,
);
// controlled panels let the parent own the MouseRegion.
if (controlled) return box;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: box,
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import "package:flutter/foundation.dart";
import "package:flutter/services.dart";
// hides the OS cursor and keeps it visually pinned at its start point for a
// drag - see macos/Runner/CursorLock.swift for the actual warp-and-suppress
// implementation. no native handler is registered on platforms other than
// macOS, so [lock]/[unlock] just fail (and get logged) instead of doing
// anything - callers dont need to guard by platform themselves, they just
// wont get live deltas or a frozen cursor there.
//
// Deltas come back over the SAME channel as a native-to-dart call rather
// than through Flutters own PointerMoveEvent - the native side intercepts
// and swallows the underlying mouse-dragged events entirely (see the Swift
// side for why), so Flutters own pointer delta cannot be trusted at all
// while locked.
class CursorLock {
CursorLock._();
static const _channel = MethodChannel("garage/cursor_lock");
// whether theres actually a native side listening. anywhere else the
// lock/unlock calls are no-ops that throw a MissingPluginException, and -
// more importantly - no deltas ever come back, so callers need to know to
// drive themselves off Flutters own pointer stream instead.
static bool get isSupported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.macOS;
static void Function(double dx, double dy)? _onDelta;
static bool _handlerInstalled = false;
static void _ensureHandlerInstalled() {
if (_handlerInstalled) return;
_handlerInstalled = true;
_channel.setMethodCallHandler((call) async {
if (call.method != "scrubDelta") return;
final args = call.arguments as Map;
final dx = (args["dx"] as num).toDouble();
final dy = (args["dy"] as num).toDouble();
_onDelta?.call(dx, dy);
});
}
// locks the cursor and starts forwarding raw pointer deltas to [onDelta]
// until [unlock] is called. only one scrub can be active at a time (theres
// only one OS cursor to hide), so this just replaces whatever listener was
// there before.
static void lock(void Function(double dx, double dy) onDelta) {
_ensureHandlerInstalled();
_onDelta = onDelta;
if (!isSupported) return;
_channel.invokeMethod("lock").catchError((e, st) {
debugPrint("CursorLock.lock failed: $e\n$st");
});
}
static void unlock() {
_onDelta = null;
if (!isSupported) return;
_channel.invokeMethod("unlock").catchError((e, st) {
debugPrint("CursorLock.unlock failed: $e\n$st");
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import "package:flutter/widgets.dart";
import "eyedropper_native.dart"
if (dart.library.html) "eyedropper_web.dart"
as impl;
// Screen-wide colour sampling, where the platform can actually do it.
//
// macOS gets NSColorSampler (see macos/Runner/ScreenColorSampler.swift) and
// Chromium browsers get the EyeDropper web api. Both of those draw their own
// magnifier loupe over the whole desktop and hand back a single colour when
// the user clicks, so theres no live preview to stream on the way - the
// preview IS the loupe.
//
// Everywhere else [nativeEyedropperAvailable] just answers false and callers
// fall back to sampling the apps own frame (see widgets/app_frame_capture.dart),
// which is window-only but works on every platform.
Future<bool> nativeEyedropperAvailable() => impl.nativeEyedropperAvailable();
/// Opens the system sampler and resolves with the picked colour, or null if
/// the user cancelled (escape) or something went wrong on the way.
Future<Color?> pickNativeScreenColor() => impl.pickNativeScreenColor();
@@ -0,0 +1,48 @@
import "package:flutter/foundation.dart";
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
// macOS side of the eyedropper. Everything else that isnt web lands here too
// and simply reports "no native sampler", which is the honest answer - theres
// no handler registered on windows/linux/ios/android.
const _channel = MethodChannel("garage/eyedropper");
bool? _cachedAvailability;
Future<bool> nativeEyedropperAvailable() async {
if (_cachedAvailability != null) return _cachedAvailability!;
if (defaultTargetPlatform != TargetPlatform.macOS) {
_cachedAvailability = false;
return false;
}
try {
_cachedAvailability = await _channel.invokeMethod<bool>("isAvailable");
} catch (error, stack) {
// an older macOS (or a runner thats never been rebuilt with the swift
// side in it) - not fatal, we just fall back to in-app sampling.
debugPrint("eyedropper: isAvailable failed: $error\n$stack");
_cachedAvailability = false;
}
return _cachedAvailability ?? false;
}
Future<Color?> pickNativeScreenColor() async {
try {
final picked = await _channel.invokeMapMethod<String, int>("pick");
if (picked == null) return null;
final r = picked["r"];
final g = picked["g"];
final b = picked["b"];
if (r == null || g == null || b == null) {
debugPrint("eyedropper: native pick returned a malformed colour $picked");
return null;
}
return Color.fromARGB(255, r, g, b);
} catch (error, stack) {
debugPrint("eyedropper: native pick failed: $error\n$stack");
return null;
}
}
@@ -0,0 +1,50 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import "dart:js_interop";
import "dart:js_interop_unsafe";
import "package:flutter/widgets.dart";
// EyeDropper is a chromium-only api at the time of writing (no firefox, no
// safari), hence the feature check rather than just assuming its there on web.
// https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper
@JS("EyeDropper")
extension type _EyeDropper._(JSObject _) implements JSObject {
external _EyeDropper();
external JSPromise<_EyeDropperResult> open();
}
extension type _EyeDropperResult._(JSObject _) implements JSObject {
external String get sRGBHex;
}
Future<bool> nativeEyedropperAvailable() async =>
globalContext.has("EyeDropper");
Future<Color?> pickNativeScreenColor() async {
try {
final result = await _EyeDropper().open().toDart;
return _parseHex(result.sRGBHex);
} catch (error, stack) {
// cancelling with escape rejects the promise with an AbortError, so this
// path is completely normal - still worth a line in the console though.
debugPrint("eyedropper: web pick ended without a colour: $error\n$stack");
return null;
}
}
Color? _parseHex(String value) {
final hex = value.replaceAll("#", "");
if (hex.length != 6) {
debugPrint("eyedropper: unexpected sRGBHex \"$value\"");
return null;
}
final parsed = int.tryParse(hex, radix: 16);
if (parsed == null) {
debugPrint("eyedropper: could not parse sRGBHex \"$value\"");
return null;
}
return Color(0xFF000000 | parsed);
}
+160
View File
@@ -0,0 +1,160 @@
import "dart:math" as math;
import "package:flutter/widgets.dart";
/// Where a popover should actually be placed, once the space around its
/// trigger has been measured.
///
/// Feed [targetAnchor]/[followerAnchor]/[offset] straight into a
/// [CompositedTransformFollower], and clamp the popup's own box with
/// [maxWidth]/[maxHeight].
class PopoverPlacement {
const PopoverPlacement({
required this.targetAnchor,
required this.followerAnchor,
required this.offset,
required this.maxWidth,
required this.maxHeight,
required this.openBelow,
required this.openToRight,
});
final Alignment targetAnchor;
final Alignment followerAnchor;
final Offset offset;
final double maxWidth;
final double maxHeight;
/// Which way the flip actually resolved. Callers that draw something
/// direction-dependent (a tail, a chevron) need to know.
final bool openBelow;
final bool openToRight;
}
/// Which axis a popover moves along relative to its trigger.
enum PopoverAxis {
/// Drops below the trigger (or flips above). Selects, dropdowns, menubar
/// menus - the popup is stacked under the thing that opened it.
vertical,
/// Flies out to the side of the trigger (or flips to the other side).
/// Submenus - the popup sits beside the thing that opened it.
horizontal,
}
/// Works out where a popover fits.
///
/// The rule is "keep the preferred side unless it doesn't fit and the other
/// side is genuinely better" - NOT "flip whenever the preferred side is
/// tight". A popover that flips the moment it's a pixel short would jitter
/// between sides as the window resizes, and would flip even when the opposite
/// side has less room than the side it left.
///
/// [preferredSize] is what the popup would like; when neither side can give it
/// that, the roomier side wins and the returned max dimensions clamp the popup
/// to what's actually there. Pass [Size.zero] if the size isn't known up front
/// (a shrink-wrapping menu) - the flip then falls back to a pure
/// which-side-has-more-room comparison.
PopoverPlacement resolvePopoverPlacement({
required RenderBox target,
required RenderBox overlay,
required Size preferredSize,
PopoverAxis axis = PopoverAxis.vertical,
bool preferBelow = true,
bool preferRight = true,
double gap = 0.0,
double margin = 8.0,
}) {
final targetOffset = target.localToGlobal(Offset.zero, ancestor: overlay);
final overlaySize = overlay.size;
final targetSize = target.size;
final roomBelow = overlaySize.height - targetOffset.dy - targetSize.height;
final roomAbove = targetOffset.dy;
final roomRight = overlaySize.width - targetOffset.dx;
final roomLeft = targetOffset.dx + targetSize.width;
// for a horizontal popover the sideways room is measured from the trigger's
// EDGES (it sits beside the trigger), not from its near edge the way a
// vertically-stacked popover measures its own left/right alignment room.
final roomRightOfTarget =
overlaySize.width - targetOffset.dx - targetSize.width;
final roomLeftOfTarget = targetOffset.dx;
bool fits(double room, double needed) => room >= needed + margin;
late final bool openBelow;
late final bool openToRight;
switch (axis) {
case PopoverAxis.vertical:
final needed = preferredSize.height + gap;
openBelow = preferBelow
? (fits(roomBelow, needed) || roomBelow >= roomAbove)
: !(fits(roomAbove, needed) || roomAbove >= roomBelow);
// horizontal here is just which way the popup extends from its anchor
// corner; it never sits beside the trigger, so it measures from the
// near edge.
final neededW = preferredSize.width;
openToRight = preferRight
? (fits(roomRight, neededW) || roomRight >= roomLeft)
: !(fits(roomLeft, neededW) || roomLeft >= roomRight);
case PopoverAxis.horizontal:
final needed = preferredSize.width + gap;
openToRight = preferRight
? (fits(roomRightOfTarget, needed) ||
roomRightOfTarget >= roomLeftOfTarget)
: !(fits(roomLeftOfTarget, needed) ||
roomLeftOfTarget >= roomRightOfTarget);
final neededH = preferredSize.height;
openBelow = preferBelow
? (fits(roomBelow + targetSize.height, neededH) ||
roomBelow >= roomAbove)
: !(fits(roomAbove + targetSize.height, neededH) ||
roomAbove >= roomBelow);
}
final double availableHeight;
final double availableWidth;
switch (axis) {
case PopoverAxis.vertical:
availableHeight = (openBelow ? roomBelow : roomAbove) - gap - margin;
availableWidth = (openToRight ? roomRight : roomLeft) - margin;
case PopoverAxis.horizontal:
// a side-flying popup is free to run the full height of the overlay
// from wherever it starts, so its height budget is measured from the
// trigger's own top/bottom edge rather than past it
availableHeight =
(openBelow
? roomBelow + targetSize.height
: roomAbove + targetSize.height) -
margin;
availableWidth =
(openToRight ? roomRightOfTarget : roomLeftOfTarget) - gap - margin;
}
final Alignment targetAnchor;
final Alignment followerAnchor;
final Offset resolvedOffset;
switch (axis) {
case PopoverAxis.vertical:
targetAnchor = Alignment(openToRight ? -1 : 1, openBelow ? 1 : -1);
followerAnchor = Alignment(openToRight ? -1 : 1, openBelow ? -1 : 1);
resolvedOffset = Offset(0, openBelow ? gap : -gap);
case PopoverAxis.horizontal:
targetAnchor = Alignment(openToRight ? 1 : -1, openBelow ? -1 : 1);
followerAnchor = Alignment(openToRight ? -1 : 1, openBelow ? -1 : 1);
resolvedOffset = Offset(openToRight ? gap : -gap, 0);
}
return PopoverPlacement(
targetAnchor: targetAnchor,
followerAnchor: followerAnchor,
offset: resolvedOffset,
maxWidth: math.max(0.0, availableWidth),
maxHeight: math.max(0.0, availableHeight),
openBelow: openBelow,
openToRight: openToRight,
);
}
+940
View File
@@ -0,0 +1,940 @@
import "package:flutter/gestures.dart";
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "context_menu.dart";
import "field_error.dart";
import "surface.dart";
import "semantics_scope.dart";
import "menu.dart";
import "theme/colour_scheme.dart";
import "theme/garage_theme.dart";
/// Where a [PropertyRow] splits label from control, as a fraction of the
/// row's width.
///
/// Blender doesnt give the label a fixed column - the split tracks the panel,
/// which is why the label/control pair always reads as centred no matter how
/// wide the editor gets. 0.4 is the split factor Blender itself defaults to
/// for `use_property_split` layouts.
const double kPropertySplitFactor = 0.4;
/// Gap between a property row's label column and its control.
const double kPropertyLabelGap = 10.0;
/// One property row: right-aligned label up to the split, control taking the
/// rest. Matches Blender's "Location X |" layout.
///
/// This is the app's form field - a labelled row that goes inside a
/// [PropertiesSection], with an optional right-click reset/copy/paste menu
/// via [PropertyActions].
class PropertyRow extends StatelessWidget {
const PropertyRow({
super.key,
required this.label,
required this.scheme,
required this.child,
this.subtitle,
this.description,
this.error,
this.action,
this.actions,
this.split = kPropertySplitFactor,
this.labelless = false,
});
final String label;
final ColourScheme scheme;
final Widget child;
/// Second line under the label, INSIDE the label column - so it stays right
/// aligned against the split the way the label is. For naming the value
/// ("Used for account notifications"), not explaining it.
final String? subtitle;
/// Full width line under the whole row, spanning the label AND the control.
/// For copy about the setting rather than about the field - consequences,
/// caveats, what changes when you change it.
final String? description;
/// Why the value was refused. Reddens the control's outline and says why
/// underneath it, in place of a toast that would float away from the field
/// it was complaining about.
final String? error;
/// Beside the label, on its line - the same slot [SettingsRow] has, and the
/// onboarding flow's ProductField before it. A muted "Required" or
/// "Optional", usually.
///
/// Styled here, at the subtitle's size and colour, so it reads as a tag on
/// the label rather than a second label. A caller setting its own style
/// still wins.
///
/// Not to be confused with [actions], which is the right-click menu.
final Widget? action;
final PropertyActions? actions;
/// fraction of the row width sitting left of the split. 0.5 would put the
/// control's left edge dead centre.
final double split;
final bool labelless;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final density = theme.density;
final captionStyle = TextStyle(
fontSize: density.textXxs,
color: scheme.mutedForeground,
);
final row = Container(
// a MINIMUM, not a fixed height. rows holding a single control already
// measure controlHeight on their own; this is what stops a plain-Text
// row (Type, Collection) collapsing to its ~11px line box and reading
// as a different rhythm to the field rows above it. rows that are
// legitimately taller - Size stacks two fields in a ButtonGroup - grow
// past it untouched, which a fixed height would squash.
//
// It sits a step ABOVE controlHeight on purpose. The row's content is
// not always the control: a label plus a subtitle is laid out on the
// font's own metrics and comes out ~25 against a 23px compact control,
// so at plain controlHeight the LABEL set the row height and the
// control went along with it - backwards, and it moved with whatever
// font resolved. With the minimum a little above both, neither one
// drives it and the row is the same height either way.
constraints: BoxConstraints(minHeight: density.propertyRowHeight),
padding: density.buttonPadding.copyWith(top: 0, bottom: 0),
// the content shrink-wraps and is centred in that minimum rather than
// being stretched to fill it, so a taller row grows symmetrically
// instead of hanging off the top.
child: Align(
alignment: Alignment.center,
heightFactor: 1.0,
child: Builder(
builder: (context) {
if (labelless) {
return PropertySlotScope(
child: FieldErrorScope(
invalid: error != null,
child: PropertyLabelScope(label: label, child: child),
),
);
}
// A flex split, NOT a LayoutBuilder. LayoutBuilder builds its child
// DURING layout, and an EditableText in that child marks itself
// needing layout as it builds - which trips
// _debugRelayoutBoundaryAlreadyMarkedNeedsLayout and takes the whole
// subtree down with a focus-scope assert behind it. Only shows up
// once a row holding a TextField is rebuilt mid-frame (a route swap
// under it was enough), which is why the inspector never hit it.
//
// Geometry is unchanged: the gap comes out of the LABEL'S PADDING
// rather than its width, so the label's text still ends at
// `width * split - gap` and the control's left edge still lands
// exactly on the split.
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: (split * 1000).round(),
child: Padding(
padding: const EdgeInsetsDirectional.only(
end: kPropertyLabelGap,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (action == null)
Text(
label,
textAlign: TextAlign.right,
style: TextStyle(color: scheme.rowText),
overflow: TextOverflow.ellipsis,
)
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
textAlign: TextAlign.right,
style: TextStyle(color: scheme.rowText),
overflow: TextOverflow.ellipsis,
),
),
SizedBox(width: density.gapXs),
DefaultTextStyle.merge(
style: captionStyle,
child: action!,
),
],
),
if (subtitle != null)
Text(
subtitle!,
textAlign: TextAlign.right,
style: captionStyle,
),
],
),
),
),
// the row's label is the control's name as far as a screen
// reader is concerned - publish it so the control can pick it
// up instead of announcing itself as an anonymous checkbox.
Expanded(
flex: ((1 - split) * 1000).round(),
child: PropertySlotScope(
child: FieldErrorScope(
invalid: error != null,
child: PropertyLabelScope(label: label, child: child),
),
),
),
],
);
},
),
),
);
final complaint = error;
final description = this.description;
// ALWAYS the column, even with nothing under the row - same as
// settings_list.dart. A row that changes SHAPE when it gains a line
// under it puts a different widget type at that position, so Flutter
// throws the subtree away and builds a new one, and that takes the
// CONTROL'S element with it. A control thats only just been built has
// nothing to animate from, so the outline turned up already red.
Widget content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
row,
if (description != null)
Padding(
// horizontal inset matches the row's, so the box's edges line
// up with the label column and the control above it rather
// than floating inside them.
padding: density.buttonPadding.copyWith(
top: density.gapXs,
bottom: 0,
),
child: OutlinedContainer(
// the outline field's own pair, not OutlinedContainer's
// defaults - a description sits among outline controls and
// should read as the same kind of surface. the default
// borderColor is `muted`, which on most schemes is close
// enough to the section fill to be invisible.
backgroundColor: scheme.controlFill,
borderColor: scheme.controlBorder,
borderWidth: density.controlBorderWidth,
// Md, matching the controls it sits under. the default is
// Xl, which next to a section at Sm reads as a pill.
borderRadius: theme.borderRadiusMd,
padding: EdgeInsets.symmetric(
horizontal: density.buttonPaddingX,
vertical: density.gapXs,
),
child: Text(
description,
textAlign: TextAlign.center,
style: captionStyle,
),
),
),
// under the control, where the control is - the outline says which
// field, this says what about it.
if (complaint != null)
Padding(
padding: density.buttonPadding.copyWith(
top: density.gapXs,
bottom: 0,
),
child: Text(
complaint,
textAlign: TextAlign.center,
style: captionStyle.copyWith(color: scheme.destructive),
),
),
],
);
// the row's height is what moves when a complaint arrives under it, so
// it eases rather than jumping. Anchored top, or the rows above the
// rejected one get shoved about by it.
content = AnimatedSize(
duration: kFieldErrorDuration,
curve: kFieldErrorCurve,
alignment: Alignment.topCenter,
child: content,
);
final actions = this.actions;
if (actions == null || !actions.hasAnyAction) return content;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (details) {
showContextMenu(
context: context,
globalPosition: details.globalPosition,
onDismissed: () {},
items: [
MenuButton(
enabled: actions.canReset,
leading: const Icon(LucideIcons.rotate_ccw),
onPressed: (_) => actions.reset(),
child: const Text("Reset to Default"),
),
MenuButton(
enabled: actions.canCopy,
leading: const Icon(LucideIcons.copy),
onPressed: (_) => actions.copy(),
child: const Text("Copy"),
),
MenuButton(
enabled: actions.canPaste,
leading: const Icon(LucideIcons.clipboard_paste),
onPressed: (_) => actions.paste(),
child: const Text("Paste"),
),
],
);
},
child: content,
);
}
}
/// Context-menu behaviour for a property row. It intentionally carries a typed
/// value instead of raw text, so a copied colour cannot be pasted into a width
/// field just because both can be displayed as strings.
class PropertyActions<T> {
const PropertyActions({
required this.value,
required this.typeKey,
this.defaultValue,
this.onReset,
this.onPaste,
});
final T value;
final String typeKey;
final T? defaultValue;
final ValueChanged<T>? onReset;
final ValueChanged<T>? onPaste;
bool get canCopy => true;
bool get canReset => defaultValue != null && onReset != null;
bool get canPaste => onPaste != null && _PropertyClipboard.canPaste(typeKey);
bool get hasAnyAction => canCopy || canReset || onPaste != null;
void copy() {
_PropertyClipboard.copy(typeKey: typeKey, value: value);
}
void reset() {
final defaultValue = this.defaultValue;
if (defaultValue == null) return;
FocusManager.instance.primaryFocus?.unfocus();
onReset?.call(defaultValue);
}
void paste() {
final pasted = _PropertyClipboard.valueFor<T>(typeKey);
if (pasted == null) return;
FocusManager.instance.primaryFocus?.unfocus();
onPaste?.call(pasted);
}
}
class _PropertyClipboard {
static String? _typeKey;
static Object? _value;
static void copy({required String typeKey, required Object? value}) {
_typeKey = typeKey;
_value = value;
}
static bool canPaste(String typeKey) => _typeKey == typeKey;
static T? valueFor<T>(String typeKey) {
if (!canPaste(typeKey)) return null;
final value = _value;
if (value is! T) return null;
return value;
}
}
/// icon + name bar that sits above a properties panel's sections - blender's
/// "[icon] Cube" row. Use this for panels whose subject is fixed (a settings
/// panel, a canvas panel) - a panel whose identity changes with a live
/// selection (an object inspector) usually wants its own header instead, tied
/// to that selection.
class PanelHeader extends StatelessWidget {
const PanelHeader({
super.key,
required this.icon,
required this.title,
required this.scheme,
this.trailing,
this.bottomPadding = 8,
this.titleWidget,
});
final IconData icon;
final String title;
final ColourScheme scheme;
// optional row of action widgets (icon buttons, usually) after the title -
// only the agent panel needs this so far (copy debug json / clear), every
// other PanelHeader call site just leaves it null and gets the old layout.
final Widget? trailing;
// replaces the plain Text(title) in the middle slot when given - the agent
// panel uses this for its thread switcher. [title] is still required even
// then; its what a screen reader/tooltip falls back to and keeps every
// other call site simple (they never pass this at all).
final Widget? titleWidget;
// a panel that fades its own content in under this header (ScrollEdgeFade)
// wants that whitespace living INSIDE the fade zone instead of sitting
// above it as dead space the fade never touches - pass 0 here and put the
// same gap back as a SizedBox ahead of the faded child. Every other call
// site just takes the default and looks exactly as before.
final double bottomPadding;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(10, 10, 10, bottomPadding),
child: Row(
children: [
// 18px slot round a 12px glyph - same icon column the explorer rows
// use, so the panels all line up down the left edge.
SizedBox(
width: 18,
child: Center(
child: Icon(icon, size: 12, color: scheme.foreground),
),
),
const SizedBox(width: 2),
Expanded(
// header:true so a screen reader can jump panel to panel by
// heading instead of walking every control in between. The label
// is [title] even when titleWidget replaces the text, which is
// what that field's doc comment already promised.
child: Semantics(
header: true,
label: title,
child:
titleWidget ??
Text(
title,
style: GarageTheme.of(
context,
).typography.medium.copyWith(color: scheme.foreground),
overflow: TextOverflow.ellipsis,
),
),
),
if (trailing != null) trailing!,
],
),
);
}
}
/// Shared collapsible section treatment for Blender-style properties panels -
/// the app's version of a form group. Holds a title bar (tap to collapse) and
/// a list of [PropertyRow]s (or any other rows).
class PropertiesSection extends StatelessWidget {
const PropertiesSection({
super.key,
required this.title,
required this.scheme,
required this.collapsed,
required this.onToggle,
required this.rows,
this.subtitle,
this.trailing,
this.actions = const [],
});
final String title;
final ColourScheme scheme;
final bool collapsed;
final VoidCallback onToggle;
final List<Widget> rows;
/// Second line under the title, inside the header bar - the section's own
/// version of [PropertyRow.subtitle]. Names what the section holds. Stays
/// visible while collapsed, because it is part of the section's identity
/// rather than part of its body.
final String? subtitle;
/// What the section can DO, as opposed to what it holds - a Save, a Reset, a
/// Delete. Lives in a band at the bottom, separated from the rows and toned
/// off them, so it reads as the section acting on itself rather than as one
/// more property that happens to be a button.
///
/// Toned between the section's own fill and `muted` rather than sat on
/// `muted` itself. Straight muted is background - 7.4 while the section is
/// background + 5.1, so the band was landing twelve points under the card it
/// belongs to and within three of the editor's chrome - a hole cut through
/// the pane rather than a floor under the rows. Mixed, it lands within about
/// a point of whatever surface the section is sitting ON in every scheme we
/// ship, which is what a footer reads as. Collapses with the rows - a
/// collapsed section shows nothing but its title bar.
/// Sits at the far end of the header bar, level with the title.
///
/// For saying something ABOUT the section rather than doing something to it
/// - a status, a badge, a count. Actions belong in [actions], which has its
/// own band under the rows; this stays visible while the section is
/// collapsed, because whatever it says is part of how you recognise the
/// section in a list of them.
final Widget? trailing;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final density = theme.density;
final radius = BorderRadius.circular(theme.radiusSm);
// null unless a PropertiesList is above us. Sections work standalone and
// always have; this is the only thing that changes when one isnt.
final reorder = PropertiesReorderScope.maybeOf(context);
final captionStyle = TextStyle(
fontSize: density.textXxs,
color: scheme.mutedForeground,
);
return Container(
// bottom is gapLg, not gapXs: this margin is what separates one section
// from the NEXT one, and gapXs is the step used between ROWS INSIDE a
// section - so at gapXs a run of sections read as one striped block
// rather than as separate subjects, which is the whole point of giving
// each one its own. Three times the inner step, so the boundary between
// two sections is unambiguously bigger than the boundary between two
// rows. The sides stay gapXs; thats an inset from the pane edge, a
// different job.
margin: EdgeInsets.fromLTRB(
density.gapXs,
0,
density.gapXs,
density.gapLg,
),
decoration: BoxDecoration(
color: scheme.card,
border: Border.all(color: scheme.propertiesSectionBorder),
borderRadius: radius,
),
child: ClipRRect(
borderRadius: radius,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MergeSemantics(
child: Semantics(
container: true,
button: true,
header: true,
expanded: !collapsed,
onTap: onToggle,
child: GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: Container(
padding: theme.density.buttonPadding,
child: Row(
children: [
AnimatedRotation(
turns: collapsed ? -0.25 : 0.0,
duration: const Duration(milliseconds: 120),
child: Icon(
LucideIcons.chevron_down,
size: theme.iconTheme.small.size,
color: scheme.mutedForeground,
),
),
SizedBox(width: density.gapSm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: theme.typography.semiBold.copyWith(
color: scheme.rowText,
),
),
if (subtitle != null)
Text(subtitle!, style: captionStyle),
],
),
),
if (trailing != null) ...[
SizedBox(width: density.gapSm),
trailing!,
],
// The grab handle, when this section is inside a
// PropertiesList that lets it move. Last in the row,
// on the far edge - the left of the header belongs to
// the chevron, and two icons stacked there read as one
// control with two halves.
//
// Nothing at all when it cant move - not a disabled
// icon, not reserved space - so a page that never
// reorders looks exactly as it did before any of this
// existed.
if (reorder != null && reorder.draggable) ...[
SizedBox(width: density.gapSm),
_SectionDragHandle(
slot: reorder.slot,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
// grip_horizontal: three across, two down. The
// list reorders VERTICALLY, and a grip whose
// rows run the same way as the travel is the one
// that reads as "drag me up and down".
//
// medium, not small - at the control icon size
// six dots turn into a smudge before they read
// as a texture you can grab.
//
// And well under mutedForeground, which is the
// colour of text you are meant to READ. A handle
// isnt read, its found - it only has to be there
// when you look for it, and at full muted it was
// competing with the section's own subtitle.
// Theres no token below muted, so this is muted
// taken down rather than a surface colour
// borrowed for a foreground job.
child: Icon(
LucideIcons.grip_horizontal,
size: theme.iconTheme.medium.size,
color: scheme.mutedForeground.withValues(
alpha: 0.25,
),
),
),
),
],
],
),
),
),
),
),
if (!collapsed) ...[
for (var i = 0; i < rows.length; i++) ...[
if (i > 0) SizedBox(height: density.gapXs),
rows[i],
],
// deliberately bigger than the between-rows step - the last row
// was sitting right on the section's bottom edge.
SizedBox(height: density.gapMd),
if (actions.isNotEmpty) ...[
Divider(color: scheme.propertiesSectionBorder),
Container(
// most of the way to muted, not all of it - see [actions].
// Lerped off the section's own fill so it tracks whatever
// the section is filled with instead of being pinned to a
// token two surfaces below it.
color: Color.lerp(scheme.card, scheme.muted, 0.45),
padding: EdgeInsets.symmetric(
horizontal: density.buttonPaddingX,
vertical: density.gapSm,
),
// the Row takes the full width, which is what makes the band
// span edge to edge rather than shrink to its buttons.
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (var i = 0; i < actions.length; i++) ...[
if (i > 0) SizedBox(width: density.gapSm),
actions[i],
],
],
),
),
],
],
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// reorderable lists of sections
// ─────────────────────────────────────────────────────────────────────────
/// One section in a [PropertiesList].
///
/// The flags live here rather than on [PropertiesSection] because a call site
/// almost never hands the list a bare section - it hands it a widget of its
/// own that renders one inside (`_GrantSection`, `_SubscriptionSection`). The
/// list cant read a field off somebody elses subtree, so what it needs to know
/// about an entry has to be said where the list can see it.
/// Which end of the list a pinned section is held at.
///
/// A roles list wants both: Owner at the top, Everyone at the bottom, and
/// neither of them anywhere else. A bool could only ever say "top", so the
/// section that belongs last ended up second from first.
enum PropertiesPin { top, bottom }
class PropertiesEntry {
const PropertiesEntry({
required this.id,
required this.child,
this.pin,
this.movable = true,
});
/// Stable across rebuilds, and what [PropertiesList.onReorder] reports.
///
/// NOT the position. The set changes under you - a grant revoked, a project
/// added - and an order stored as indices quietly means something else the
/// next time the list is a different length.
final String id;
final Widget child;
/// Held at one end of the list, and never dragged.
///
/// null - the default - means it moves with everything else. A pinned
/// section is rendered outside the reorderable list entirely, which is what
/// stops the list shifting it aside mid-drag and then correcting the drop.
final PropertiesPin? pin;
bool get pinned => pin != null;
/// Can this one be picked up? Default true - everything moves unless said
/// otherwise.
///
/// This is about the HANDLE, not the slot: an unmovable section cant be
/// dragged, but the ones around it can still move past it, so its index can
/// change. If a section has to stay put, [pinned] is the flag for that.
final bool movable;
}
/// A column of [PropertiesSection]s the user can drag into their own order.
///
/// It reports the order and stores nothing. Persisting it belongs to the
/// consumer - the kit has no business knowing where an app keeps preferences,
/// and one storage abstraction serving four call sites is exactly the thing it
/// shouldnt grow.
class PropertiesList extends StatefulWidget {
const PropertiesList({
super.key,
required this.entries,
required this.onReorder,
});
final List<PropertiesEntry> entries;
/// The ids, in the order they now sit in, pinned ones included.
///
/// The whole order rather than (oldIndex, newIndex): what a consumer stores
/// IS an order, and turning a pair of indices back into one is the same
/// dozen lines at every call site.
final void Function(List<String> order) onReorder;
@override
State<PropertiesList> createState() => _PropertiesListState();
}
class _PropertiesListState extends State<PropertiesList> {
/// One slot per entry id, kept for the life of the list.
///
/// The point is the IDENTITY, not the contents. A slot's index is written
/// in place as things move, so the scope handing it to a section never
/// changes and the section never rebuilds - which is what makes a drag
/// animate offsets instead of reconstructing every section's subtree, text
/// fields and all, each time the gap shifts.
final Map<String, PropertiesReorderSlot> _slots = {};
PropertiesReorderSlot _slotFor(String id, int index) {
final slot = _slots.putIfAbsent(id, () => PropertiesReorderSlot(index));
slot.index = index;
return slot;
}
@override
Widget build(BuildContext context) {
final entries = widget.entries;
final onReorder = widget.onReorder;
// Pinned sections are rendered OUTSIDE the reorderable list, not held at
// index 0 inside it.
//
// Inside, the list owns them: it shifts them out of the way as you drag
// past, and the only thing that can be done about it is to fix up the
// result in onReorder - so the section you were dragging visibly took the
// top slot and then snapped back one. A correction after the fact, and it
// looked like one.
//
// Out here theres nothing to correct. The pinned block cant move because
// it isnt in a list that moves things, nothing can be dropped above it
// because there is no slot above it, and the top of the reorderable list
// IS second place - so dragging to the top settles there instead of
// bouncing.
final top = entries.where((e) => e.pin == PropertiesPin.top).toList();
final bottom = entries.where((e) => e.pin == PropertiesPin.bottom).toList();
final movable = entries.where((e) => e.pin == null).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// no scope, so no handle - a pinned section renders exactly as it
// would anywhere else.
for (final entry in top) entry.child,
if (movable.isNotEmpty) _reorderable(movable, top, bottom, onReorder),
for (final entry in bottom) entry.child,
],
);
}
Widget _reorderable(
List<PropertiesEntry> movable,
List<PropertiesEntry> top,
List<PropertiesEntry> bottom,
void Function(List<String> order) onReorder,
) {
// ReorderableList, from flutter/widgets - NOT material's
// ReorderableListView.
//
// The reordering machinery has allways lived in the widgets library;
// ReorderableListView is only material's wrapper round it. Using the
// wrapper meant a Material ancestor, and Material brings its own
// DefaultTextStyle and IconTheme - which sit UNDER garage's and quietly
// replaced the kit's typography and density derived sizes with flutter's
// defaults for everything inside the list. The widgets version wants
// WidgetsLocalizations and an Overlay, and GarageApp's WidgetsApp provides
// both.
return ReorderableList(
// it sits inside the pane's scroll view, so it takes its height from its
// children and doesnt scroll itself.
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: movable.length,
// the section, unchanged, while its being dragged - a section is a flat
// card on a flat pane and has nothing to lift off it.
proxyDecorator: (child, index, animation) => child,
itemBuilder: (context, i) {
final entry = movable[i];
return PropertiesReorderScope(
key: ValueKey(entry.id),
slot: _slotFor(entry.id, i),
draggable: entry.movable,
child: entry.child,
);
},
onReorder: (oldIndex, newIndex) {
// ReorderableList reports where the item would be INSERTED, which is
// one past itself when its moving down.
if (newIndex > oldIndex) newIndex -= 1;
final next = [...movable];
next.insert(newIndex, next.removeAt(oldIndex));
// the whole order, ends included: theyre still part of what the
// consumer stores, theyre just not part of the bit that moves.
onReorder([
for (final e in top) e.id,
for (final e in next) e.id,
for (final e in bottom) e.id,
]);
},
);
}
}
/// Tells a [PropertiesSection] which slot of a [PropertiesList] it is in.
///
/// The handle has to be drawn by the SECTION - it belongs in the header beside
/// the chevron - but only the list knows the index a drag has to quote. So the
/// list puts the slot in scope and the section picks it up. Any depth of
/// wrapper in between is fine, which is the point: call sites wrap their
/// sections in widgets of their own everywhere.
class PropertiesReorderScope extends InheritedWidget {
const PropertiesReorderScope({
super.key,
required this.slot,
required this.draggable,
required super.child,
});
final PropertiesReorderSlot slot;
final bool draggable;
static PropertiesReorderScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<PropertiesReorderScope>();
/// Deliberately NOT sensitive to the slot's index.
///
/// The index changes constantly while something is being dragged, and
/// notifying on it rebuilt every section in the list each time the gap
/// moved - four subtrees of rows and text fields, mid-animation, which is
/// what made the drag feel like it was catching. The slot is mutable so the
/// handle can read the current index when a drag actually starts, and the
/// only thing worth a rebuild is whether the section can be dragged at all.
@override
bool updateShouldNotify(PropertiesReorderScope old) =>
draggable != old.draggable || !identical(slot, old.slot);
}
/// Where a section currently sits, as a thing rather than a number.
///
/// A widget field would have to be replaced to change, and replacing it is
/// what triggers the rebuilds this exists to avoid. So the object stays and
/// the number inside it moves.
class PropertiesReorderSlot {
PropertiesReorderSlot(this.index);
int index;
}
/// The grab handle: starts a reorder on pointer down, reading the section's
/// position AT THAT MOMENT.
///
/// This is [ReorderableDragStartListener] with one difference, and it is the
/// whole point: that one takes its index as a constructor argument, so it has
/// to be rebuilt every time the index changes. This one reads it off the slot
/// when the pointer actually goes down, so nothing above it needs rebuilding
/// while a drag is in flight.
class _SectionDragHandle extends StatelessWidget {
const _SectionDragHandle({required this.slot, required this.child});
final PropertiesReorderSlot slot;
final Widget child;
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (event) {
final list = SliverReorderableList.maybeOf(context);
list?.startItemDragReorder(
index: slot.index,
event: event,
recognizer: ImmediateMultiDragGestureRecognizer(debugOwner: this)
..gestureSettings = MediaQuery.maybeGestureSettingsOf(context),
);
},
child: child,
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import "package:flutter/widgets.dart";
/// Wraps a scrollable panel body so content approaching the top or bottom
/// edge fades to transparent over [fadeExtent] pixels instead of getting cut
/// off flat by the scroll view's own default hard clip. Purely a paint
/// effect - the scroll views clip still keeps everything contained, this
/// just masks the alpha near each edge on top of that.
///
/// Colour-blind on purpose: the gradient only carries alpha (dstIn blends
/// against whatever's already painted), so it works unchanged over any panel
/// background/scheme rather than needing its own colour token.
class ScrollEdgeFade extends StatelessWidget {
const ScrollEdgeFade({super.key, required this.child, this.fadeExtent = 6});
final Widget child;
final double fadeExtent;
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback: (bounds) {
// fraction of the box height the fade eats into from each end - a
// fixed pixel extent thats clamped so a very short panel doesnt end
// up with the two fades overlapping and cancelling out the middle.
final fraction = bounds.height > 0
? (fadeExtent / bounds.height).clamp(0.0, 0.5)
: 0.0;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, fraction, 1 - fraction, 1.0],
colors: const [
Color(0x00ffffff),
Color(0xffffffff),
Color(0xffffffff),
Color(0x00ffffff),
],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: child,
);
}
}
+167
View File
@@ -0,0 +1,167 @@
import "dart:ui" show lerpDouble;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// Rest -> hover geometry. A hairline: at rest its barely a mark on the edge
// of the pane, and hover only just thickens it. The pill radius falls out of
// the thickness so the two never drift.
const double _restThickness = 2;
const double _hoverThickness = 3.5;
const double _restAlpha = 0.35;
const double _hoverAlpha = 0.55;
const double _dragAlpha = 0.7;
// how long the fatten/brighten takes. short - this is a pointer response, not
// a transition.
const Duration _grow = Duration(milliseconds: 140);
/// The Garage scroll thumb.
///
/// Every scrollable in a [GarageApp] gets one of these through
/// GarageScrollBehavior, so nothing has to wrap itself in a scrollbar by hand.
/// Flutters own default is a [RawScrollbar] in stock grey - square ended, 8px,
/// no relation to the scheme - which is what you were seeing before this
/// existed.
///
/// It floats OVER the content (no track, no gutter) so turning it on doesnt
/// reflow anything, and it holds itself visible while the pointer is anywhere
/// in the pane rather than only while youre actually scrolling.
class GarageScrollbar extends StatefulWidget {
const GarageScrollbar({super.key, required this.child, this.controller});
final Widget child;
/// The controller of the scrollable underneath. Comes straight off
/// ScrollableDetails when this is built by the scroll behaviour; null falls
/// back to the PrimaryScrollController the same way [RawScrollbar] does.
final ScrollController? controller;
@override
State<GarageScrollbar> createState() => _GarageScrollbarState();
}
class _GarageScrollbarState extends State<GarageScrollbar> {
bool _hovered = false;
bool _dragging = false;
ScrollController? get _controller =>
widget.controller ?? PrimaryScrollController.maybeOf(context);
// RawScrollbar only tolerates thumbVisibility when its controller is wired
// to exactly one live position - it asserts on everything else. We flip that
// flag on hover rather than at construction, so the same checks have to
// happen here first: a pane whose controller hasnt attached yet would
// otherwise throw the moment the pointer crossed it, and one whose content
// fits would paint a full height thumb over nothing.
bool get _canHold {
final c = _controller;
if (c == null || !c.hasClients || c.positions.length != 1) return false;
final p = c.position;
if (!p.hasContentDimensions) return false;
return p.maxScrollExtent > p.minScrollExtent;
}
void _setHovered(bool value) {
if (_hovered == value) return;
setState(() {
_hovered = value;
// a drag that ends outside the pane never reports back (the recognisers
// cancel path doesnt route through handleThumbPressEnd), so dont let the
// flag stick.
if (!value) _dragging = false;
});
}
void _setDragging(bool value) {
if (_dragging == value) return;
setState(() => _dragging = value);
}
@override
Widget build(BuildContext context) {
final scheme = GarageTheme.maybeOf(context)?.colorScheme;
// outside a GarageTheme (a bare test host, mostly) fall back to flutters
// own grey rather than asserting - a scrollbar is never worth a red screen.
final base = scheme?.mutedForeground ?? const Color(0xffbcbcbc);
final lit = _hovered || _dragging;
return MouseRegion(
opaque: false,
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
child: Listener(
onPointerUp: (_) => _setDragging(false),
onPointerCancel: (_) => _setDragging(false),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(end: lit ? 1 : 0),
duration: _grow,
curve: Curves.easeOut,
builder: (context, t, child) {
final thickness = lerpDouble(_restThickness, _hoverThickness, t)!;
final alpha = _dragging
? _dragAlpha
: lerpDouble(_restAlpha, _hoverAlpha, t)!;
return _GarageRawScrollbar(
controller: widget.controller,
thumbColor: base.withValues(alpha: alpha),
thickness: thickness,
radius: Radius.circular(thickness / 2),
// null, not false: false would mean "definitely hidden" and
// kill the fade in on scroll.
thumbVisibility: lit && _canHold ? true : null,
crossAxisMargin: 2,
mainAxisMargin: 2,
minThumbLength: 24,
onDragChanged: _setDragging,
child: child!,
);
},
child: widget.child,
),
),
);
}
}
// The only reason this subclass exists: RawScrollbar keeps its drag state to
// itself, and the thumb is meant to go full strength while youre holding it.
class _GarageRawScrollbar extends RawScrollbar {
const _GarageRawScrollbar({
required super.child,
required this.onDragChanged,
super.controller,
super.thumbVisibility,
super.thumbColor,
super.thickness,
super.radius,
super.crossAxisMargin,
super.mainAxisMargin,
super.minThumbLength,
});
final ValueChanged<bool> onDragChanged;
@override
RawScrollbarState<_GarageRawScrollbar> createState() =>
_GarageRawScrollbarState();
}
class _GarageRawScrollbarState extends RawScrollbarState<_GarageRawScrollbar> {
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
widget.onDragChanged(true);
}
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
super.handleThumbPressEnd(localPosition, velocity);
widget.onDragChanged(false);
}
}
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
// hand rolled replacements for shadcn's Checkbox (tri-state) + Switch.
// started as a pixel-identical port of shadcn_flutter 0.0.52 form/checkbox.dart
// + form/switch.dart; the colours now come from the apps own control tokens
// (ColourScheme) rather than the shadcn scheme they were ported against.
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:garage_ui/semantics_scope.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// how long a switch takes to slide/recolour. matches shadcn.
const kSwitchDuration = Duration(milliseconds: 100);
/// the three states a checkbox can be in. names match shadcn exactly so call
/// sites only have to swap their import.
enum CheckboxState implements Comparable<CheckboxState> {
checked,
unchecked,
indeterminate;
@override
int compareTo(CheckboxState other) {
return index.compareTo(other.index);
}
}
/// tri-state checkbox. drive it with [state] + [onChanged] — it does not hold
/// its own state, the parent does.
class Checkbox extends StatefulWidget {
final CheckboxState state;
final ValueChanged<CheckboxState>? onChanged;
final Widget? leading;
final Widget? trailing;
/// when true, tapping cycles checked -> unchecked -> indeterminate.
/// when false it just toggles checked/unchecked.
final bool tristate;
final bool? enabled;
final double? size;
final double? gap;
final Color? backgroundColor;
final Color? activeColor;
final Color? borderColor;
final BorderRadiusGeometry? borderRadius;
/// screen reader label. Needed unless [leading]/[trailing] already carry a
/// Text that names the box — the box itself paints a tick, which reads as
/// nothing at all.
final String? semanticLabel;
final FocusNode? focusNode;
const Checkbox({
super.key,
required this.state,
required this.onChanged,
this.leading,
this.trailing,
this.tristate = false,
this.enabled,
this.size,
this.gap,
this.backgroundColor,
this.activeColor,
this.borderColor,
this.borderRadius,
this.semanticLabel,
this.focusNode,
});
@override
State<Checkbox> createState() => _CheckboxState();
}
class _CheckboxState extends State<Checkbox> {
// shadcn keeps this always false (focus ring never wired), so the border
// width stays 1*scaling. we keep the same behaviour.
final bool _focusing = false;
bool _shouldAnimate = false;
void _changeTo(CheckboxState state) {
if (widget.onChanged != null) {
widget.onChanged!(state);
}
}
void _tap() {
if (widget.tristate) {
switch (widget.state) {
case CheckboxState.checked:
_changeTo(CheckboxState.unchecked);
break;
case CheckboxState.unchecked:
_changeTo(CheckboxState.indeterminate);
break;
case CheckboxState.indeterminate:
_changeTo(CheckboxState.checked);
break;
}
} else {
_changeTo(
widget.state == CheckboxState.checked
? CheckboxState.unchecked
: CheckboxState.checked,
);
}
}
@override
void didUpdateWidget(covariant Checkbox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.state != oldWidget.state) {
_shouldAnimate = true;
}
}
bool get enabled => widget.enabled ?? widget.onChanged != null;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final size = widget.size ?? 16 * scaling;
final gap = widget.gap ?? 8 * scaling;
// a checkbox is a control, so it takes the control tokens: the same fill
// a secondary button/field uses, and the same border every other input
// draws. (was input.scaleAlpha(0.3) over colorScheme.border - both
// shadcn-migration leftovers that left it reading as its own family.)
final backgroundColor =
widget.backgroundColor ?? theme.colorScheme.secondary;
final activeColor = widget.activeColor ?? theme.colorScheme.primary;
final borderColor = widget.borderColor ?? theme.colorScheme.controlBorder;
final borderRadius =
widget.borderRadius?.resolve(Directionality.maybeOf(context)) ??
BorderRadius.circular(theme.radiusSm);
return MergeSemantics(
child: Semantics(
container: true,
enabled: enabled,
checked: widget.state == CheckboxState.checked,
// report mixed off the actual state, not off tristate — a box can be
// handed CheckboxState.indeterminate even when tapping it only ever
// toggles, and "partly on" is still what the user needs to hear.
mixed: widget.state == CheckboxState.indeterminate ? true : null,
label: resolveSemanticLabel(context, widget.semanticLabel),
onTap: enabled ? _tap : null,
child: _buildBox(
context,
size,
gap,
backgroundColor,
activeColor,
borderColor,
borderRadius,
theme,
scaling,
),
),
);
}
Widget _buildBox(
BuildContext context,
double size,
double gap,
Color backgroundColor,
Color activeColor,
Color borderColor,
BorderRadius borderRadius,
ThemeData theme,
double scaling,
) {
return FocusableActionDetector(
enabled: enabled,
focusNode: widget.focusNode,
mouseCursor: enabled
? SystemMouseCursors.click
: SystemMouseCursors.forbidden,
shortcuts: const {
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
},
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
_tap();
return null;
},
),
},
child: GestureDetector(
onTap: enabled ? _tap : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) SizedBox(width: gap),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: size,
height: size,
decoration: BoxDecoration(
color: widget.state == CheckboxState.checked
? activeColor
: backgroundColor,
borderRadius: borderRadius,
border: Border.all(
color: !enabled
? theme.colorScheme.muted
: widget.state == CheckboxState.checked
? activeColor
: borderColor,
width: (_focusing ? 2 : 1) * scaling,
),
),
child: widget.state == CheckboxState.checked
? Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
child: SizedBox(
width: scaling * 9,
height: scaling * 6.5,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: _shouldAnimate ? 0.0 : 1.0,
end: 1.0,
),
duration: const Duration(milliseconds: 300),
// interval maps to shadcn's IntervalDuration(start: 175ms,
// duration: 300ms) — hold, then draw the tick.
curve: const Interval(175 / 300, 1.0),
builder: (context, value, child) {
return CustomPaint(
painter: AnimatedCheckPainter(
progress: value,
color: theme.colorScheme.primaryForeground,
strokeWidth: scaling * 1,
),
);
},
),
),
),
)
: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: widget.state == CheckboxState.indeterminate
? scaling * 8
: 0,
height: widget.state == CheckboxState.indeterminate
? scaling * 8
: 0,
padding: EdgeInsets.zero,
decoration: BoxDecoration(
color: activeColor,
borderRadius: BorderRadius.circular(theme.radiusXs),
),
),
),
),
if (widget.trailing != null) SizedBox(width: gap),
if (widget.trailing != null) widget.trailing!,
],
),
),
);
}
}
/// draws the little tick that animates in when a checkbox goes checked.
/// copied verbatim from shadcn so the stroke geometry stays identical.
class AnimatedCheckPainter extends CustomPainter {
final double progress;
final Color color;
final double strokeWidth;
AnimatedCheckPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final path = Path();
Offset firstStrokeStart = Offset(0, size.height * 0.5);
Offset firstStrokeEnd = Offset(size.width * 0.35, size.height);
Offset secondStrokeStart = firstStrokeEnd;
Offset secondStrokeEnd = Offset(size.width, 0);
double firstStrokeLength =
(firstStrokeEnd - firstStrokeStart).distanceSquared;
double secondStrokeLength =
(secondStrokeEnd - secondStrokeStart).distanceSquared;
double totalLength = firstStrokeLength + secondStrokeLength;
double normalizedFirstStrokeLength = firstStrokeLength / totalLength;
double normalizedSecondStrokeLength = secondStrokeLength / totalLength;
double firstStrokeProgress =
progress.clamp(0.0, normalizedFirstStrokeLength) /
normalizedFirstStrokeLength;
double secondStrokeProgress =
(progress - normalizedFirstStrokeLength).clamp(
0.0,
normalizedSecondStrokeLength,
) /
normalizedSecondStrokeLength;
if (firstStrokeProgress <= 0) {
return;
}
Offset currentPoint = Offset.lerp(
firstStrokeStart,
firstStrokeEnd,
firstStrokeProgress,
)!;
path.moveTo(firstStrokeStart.dx, firstStrokeStart.dy);
path.lineTo(currentPoint.dx, currentPoint.dy);
if (secondStrokeProgress <= 0) {
canvas.drawPath(path, paint);
return;
}
Offset secondPoint = Offset.lerp(
secondStrokeStart,
secondStrokeEnd,
secondStrokeProgress,
)!;
path.lineTo(secondPoint.dx, secondPoint.dy);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant AnimatedCheckPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
}
/// sliding on/off toggle. controlled — [value] in, [onChanged] out.
class Switch extends StatefulWidget {
final bool value;
final ValueChanged<bool>? onChanged;
final Widget? leading;
final Widget? trailing;
final bool? enabled;
final double? gap;
final Color? activeColor;
final Color? inactiveColor;
final Color? activeThumbColor;
final Color? inactiveThumbColor;
final BorderRadiusGeometry? borderRadius;
/// screen reader label. Same story as Checkbox — the track and thumb are
/// pure paint, so without this the switch is an unnamed toggle.
final String? semanticLabel;
const Switch({
super.key,
required this.value,
required this.onChanged,
this.leading,
this.trailing,
this.enabled = true,
this.gap,
this.activeColor,
this.inactiveColor,
this.activeThumbColor,
this.inactiveThumbColor,
this.borderRadius,
this.semanticLabel,
});
@override
State<Switch> createState() => _SwitchState();
}
class _SwitchState extends State<Switch> {
bool get _enabled => widget.enabled ?? widget.onChanged != null;
void _toggle() {
widget.onChanged?.call(!widget.value);
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final densityGap = theme.density.containerGap;
final gap = widget.gap ?? 8 * scaling;
final activeColor = widget.activeColor ?? theme.colorScheme.primary;
final inactiveColor =
widget.inactiveColor ?? theme.colorScheme.switchTrackInactive;
final activeThumbColor =
widget.activeThumbColor ?? theme.colorScheme.background;
final inactiveThumbColor =
widget.inactiveThumbColor ?? theme.colorScheme.foreground;
final borderRadius =
widget.borderRadius?.resolve(Directionality.maybeOf(context)) ??
BorderRadius.circular(theme.radiusXl);
return MergeSemantics(
child: Semantics(
container: true,
toggled: widget.value,
enabled: _enabled,
label: resolveSemanticLabel(context, widget.semanticLabel),
onTap: _enabled ? _toggle : null,
child: _buildTrack(
context,
theme,
scaling,
densityGap,
gap,
activeColor,
inactiveColor,
activeThumbColor,
inactiveThumbColor,
borderRadius,
),
),
);
}
Widget _buildTrack(
BuildContext context,
ThemeData theme,
double scaling,
double densityGap,
double gap,
Color activeColor,
Color inactiveColor,
Color activeThumbColor,
Color inactiveThumbColor,
BorderRadius borderRadius,
) {
return GestureDetector(
onTap: _enabled ? _toggle : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: FocusableActionDetector(
enabled: _enabled,
actions: {
ActivateIntent: CallbackAction<Intent>(
onInvoke: (intent) {
_toggle();
return true;
},
),
},
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
},
mouseCursor: _enabled
? SystemMouseCursors.click
: SystemMouseCursors.forbidden,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) SizedBox(width: gap),
AnimatedContainer(
duration: kSwitchDuration,
width: (32 + 4) * scaling,
height: (16 + 4) * scaling,
padding: EdgeInsets.all(densityGap * 0.25),
decoration: BoxDecoration(
borderRadius: borderRadius,
color: !_enabled
? theme.colorScheme.muted
: widget.value
? activeColor
: inactiveColor,
),
child: Stack(
children: [
AnimatedPositioned(
duration: kSwitchDuration,
curve: Curves.easeInOut,
left: widget.value ? 16 * scaling : 0,
top: 0,
bottom: 0,
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.radiusLg),
color: !_enabled
? theme.colorScheme.mutedForeground
: widget.value
? activeThumbColor
: inactiveThumbColor,
),
),
),
),
],
),
),
if (widget.trailing != null) SizedBox(width: gap),
if (widget.trailing != null) widget.trailing!,
],
),
),
);
}
}
+71
View File
@@ -0,0 +1,71 @@
// A label handed down to whatever control sits in a labelled row.
//
// Most controls in this app are not labelled at the call site — they sit in a
// PropertyRow (or a settings row) that already draws the name beside them, so
// a sighted user reads "Show grid" and then the box. A screen reader walking
// the tree gets the Text and the checkbox as two unrelated things, and the
// checkbox itself says nothing.
//
// The obvious fix — merging the whole row into one semantics node — falls over
// on rows holding more than one control (a Size row is two fields in a button
// group; merged, it becomes one unreadable blob). So instead the row publishes
// its label down the subtree and each control picks it up as a FALLBACK for
// its own semanticLabel. Explicit always wins.
import "package:flutter/widgets.dart";
class PropertyLabelScope extends InheritedWidget {
const PropertyLabelScope({
super.key,
required this.label,
required super.child,
});
final String label;
/// nearest enclosing row label, or null if this control isn't in one.
static String? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<PropertyLabelScope>()
?.label;
}
@override
bool updateShouldNotify(PropertyLabelScope old) => label != old.label;
}
/// Resolves the label a control should announce: its own if it was given one,
/// otherwise the row it lives in. Returns null when neither exists, which is
/// the case a control should be given an explicit label to fix.
String? resolveSemanticLabel(BuildContext context, String? explicit) {
if (explicit != null) return explicit;
return PropertyLabelScope.maybeOf(context);
}
// Marks the control slot of a labelled row, so the controls inside it can stop
// asking the call site what they should look like.
//
// A property section is a docked surface: its rows sit ON a panel, not on the
// page. An outline field in there reads as floating - it draws its own border
// and background against a background that already has one. Secondary is the
// pairing that reads as docked, and it is the ONLY correct answer inside a
// row, which makes `variant:` at those call sites a parameter whose every
// value but one is a bug.
//
// So the row publishes the fact, and TextField/Select resolve against it. This
// is the same trick PropertyLabelScope plays one widget up - the row knows
// something the control cant see, and hands it down rather than making every
// call site repeat it. Unlike the label, though, this one is NOT a fallback:
// an explicit `variant:` inside a row loses. Thats the point.
class PropertySlotScope extends InheritedWidget {
const PropertySlotScope({super.key, required super.child});
/// true when this control sits in a property row's control slot.
static bool of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<PropertySlotScope>() !=
null;
}
@override
bool updateShouldNotify(PropertySlotScope old) => false;
}
+303
View File
@@ -0,0 +1,303 @@
import "package:flutter/widgets.dart";
import "field_error.dart";
import "overlay.dart" show Tooltip;
import "surface.dart" show Divider;
import "theme/garage_theme.dart";
/// A list of label / field pairs, ruled between entries.
///
/// The other way of showing a set of values is [PropertiesSection], which puts
/// them in a bordered, collapsible card. That card is a subject - a thing with
/// a name and a state you can fold away - and a page made of nothing but those
/// reads as a stack of boxes rather than a set of settings, which is the
/// complaint that produced this.
///
/// Here theres no box and no header. Just rows with a hairline between them,
/// so what you see is the values and where one ends and the next begins.
///
/// Not a scroller: it takes its height from its rows and expects a pane to do
/// the scrolling, the same as a run of sections does.
class SettingsList extends StatelessWidget {
const SettingsList({super.key, required this.children});
/// [SettingsRow]s, usually. Anything else is laid out and ruled the same.
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
final inset = theme.density.gapSm;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < children.length; i++) ...[
// BETWEEN entries, not around them. A rule above the first row or
// under the last one draws a box, which is the thing this exists to
// not be.
//
// And the rule runs WIDER than the rows: the inset is on the rows,
// not on the list, so the line reaches slightly past the text at
// both ends. A rule that stops exactly where its content stops
// reads as the edge of a box; one that overshoots reads as a
// divider between two things.
if (i > 0) Divider(color: scheme.divider),
Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: children[i],
),
],
],
);
}
}
/// One label and one field, on a line.
class SettingsRow extends StatelessWidget {
const SettingsRow({
super.key,
required this.label,
required this.field,
this.subtitle,
this.description,
this.error,
this.action,
this.labelTooltip,
});
/// Left, and it gets whatever width the field doesnt want.
final String label;
/// Shown on hovering the label, and only the label.
///
/// For the exact thing the label is a friendly name for - a permission
/// string, an id, a unit. That belongs somewhere you can go and look for
/// it rather than in the row, where it would be a second piece of text
/// competing with the one a person actually reads.
///
/// It wraps the label text itself, not its half of the row, so the empty
/// space beside a short label doesnt trigger anything.
final WidgetBuilder? labelTooltip;
/// A second line under the label, for the words the label had to leave out.
///
/// A QUALIFIER, not an explanation - "Account, passkeys and sign-in
/// activity" under "Read", not a sentence about what reading means. If it
/// needs a sentence it isnt a settings row.
final String? subtitle;
/// A full width line UNDER the whole row, label and field both.
///
/// For copy about the setting rather than about the field - consequences,
/// caveats, what changes when you change it. [subtitle] is a qualifier and
/// has to fit beside a value; this is a paragraph and doesnt.
///
/// The same slot [PropertyRow] has, for the same reason: a sentence squashed
/// into the label column is a sentence nobody reads.
final String? description;
/// Why the value was refused, under the row and in the destructive colour.
///
/// It also reddens the field's outline, through [FieldErrorScope] - so the
/// row says which one and this says why, which is the pair a toast cant be:
/// a message that floats over the corner of the screen has left the field
/// it was about behind.
final String? error;
/// Beside the label, on its line. The onboarding flow's ProductField has the
/// same slot and puts a muted "Optional" in it; a settings row gets one so
/// "Required" doesnt have to be found out by pressing Save.
///
/// Styled here rather than by the caller: it reads at the subtitle's size
/// and colour, so its a tag ON the label rather than a second label. A
/// caller that sets its own style still wins - this only supplies the
/// default.
final Widget? action;
/// Right, at its own size.
///
/// It is NOT stretched to a column: a Select that says "Engineering" should
/// be as wide as "Engineering", and a text box thats meant to be wide can
/// say so with a SizedBox. Stretching everything to one split is what makes
/// a form of mixed controls look like a table with a ragged edge.
final Widget field;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final density = theme.density;
// textXs over textXxs - the pairing density calls a "label column" in so
// many words, and the one the consent screen reads right at.
//
// The label was inheriting the ambient body size, which is the CONTROL
// font. Against a textXxs subtitle thats 1.0 to 0.875, near enough the
// same text twice, and the label stopped reading as the name of anything.
// A step up puts it at 1.1 to 0.875 and the two lines have different jobs
// again.
final labelStyle = theme.typography.normal.copyWith(
fontSize: density.textXs,
color: theme.colorScheme.foreground,
);
Widget labelText = Text(label, style: labelStyle);
if (labelTooltip case final tip?) {
labelText = Tooltip(tooltip: tip, child: labelText);
}
final aside = action;
if (aside != null) {
labelText = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// flexible, so a long label ellipsises rather than shoving the tag
// off the end of the column
Flexible(child: labelText),
SizedBox(width: density.gapXs),
DefaultTextStyle.merge(
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
fontWeight: FontWeight.normal,
),
child: aside,
),
],
);
}
final row = Padding(
padding: EdgeInsets.only(
top: density.gapMd,
bottom: description == null && error == null
? density.gapMd
: density.gapSm,
),
child: ConstrainedBox(
// A MINIMUM, and its the height of a row that HAS a subtitle.
//
// Otherwise a list where only some rows carry a second line comes out
// ragged - the plain ones close up to a single line box and the run
// of rows has two rhythms in it. labelColumnHeight is that stack
// exactly (textXs over textXxs on the font's own line box), plus the
// gap this row puts between the two.
//
// Rows that are legitimately taller - a four line text box - grow
// past it untouched, which a fixed height would squash.
constraints: BoxConstraints(
minHeight: density.labelColumnHeight + density.gapXxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
labelText,
if (subtitle != null) ...[
// the two lines were touching. A caption sat hard against
// the thing it captions reads as a wrapped second line of
// it rather than as a note under it.
SizedBox(height: density.gapXxs),
Text(
subtitle!,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
),
),
],
],
),
),
SizedBox(width: density.gapMd),
// Flexible, loose - so the field is BOUNDED but still sizes itself.
//
// A bare child in a Row is measured with an unbounded main axis,
// and anything with a flex child inside it - a ButtonGroup with
// `fill`, a Row of Expanded - asserts the moment it sees that. As a
// loose Flexible it gets "at most whats left", so a Text or a Select
// still shrink wraps and a filled control has a width to divide.
//
// Aligned right INSIDE that slot, because the slot is a share of the
// row rather than the width of the field. Without this a select that
// wants 90px sits at the left edge of its half and lands in the
// middle of the line, which is neither one column nor the other.
Flexible(
child: Align(
alignment: Alignment.centerRight,
child: FieldErrorScope(invalid: error != null, child: field),
),
),
],
),
),
);
final note = description;
final complaint = error;
// AnimatedSize even with nothing under the row: a complaint ARRIVING is
// the interesting case, and a row that only starts animating once it has
// something to animate would jump on the way in and ease on the way out.
//
// Its the row's own height thats moving, so it grows downward - anchored
// at the top, or every row above the one that was rejected shuffles.
//
// And ALWAYS the column under it, even when theres nothing in it but the
// row. Swapping between `row` and `Column(row, ...)` puts a different
// widget type at that position, so Flutter throws the subtree away and
// builds a new one - which takes the FIELD'S element with it. A brand
// new AnimatedOpacity starts AT its target, so the outline turned up
// already red however long it had been told to take getting there.
return AnimatedSize(
duration: kFieldErrorDuration,
curve: kFieldErrorCurve,
alignment: Alignment.topCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
row,
// sat in the row's own bottom padding rather than under it, so the
// note belongs to the row above it and not to the rule below. Nudged
// up by the same amount it stands off the next one.
if (complaint != null)
Padding(
padding: EdgeInsets.only(
bottom: note == null ? density.gapMd : 0,
),
child: Text(
complaint,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.destructive,
),
),
),
if (note != null)
Padding(
padding: EdgeInsets.only(
top: complaint == null ? 0 : density.gapXxs,
bottom: density.gapMd,
),
child: Text(
note,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
);
}
}
+490
View File
@@ -0,0 +1,490 @@
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/button.dart";
import "package:garage_ui/theme/garage_theme.dart";
// A sheet that comes up from the bottom edge. The phone's version of a dialog
// for anything thats more than a sentence and two buttons - a list to pick
// from, a handful of per-item controls, a search box with results under it.
//
// Its the same surface an AlertDialog is (card, the section border, dimmed
// page behind), just anchored to the bottom and only rounded on top, because
// thats where a thumb is. Flat like everything else here - no shadow, the
// border and the scrim are what lift it off the page.
//
// On a wide window it doesnt go edge to edge. A 1400px sheet of four rows is
// a banner, not a sheet, so it caps at [maxWidth] and sits centred.
/// Shows a modal sheet and returns whatever it gets popped with.
///
/// [builder] builds the body. The sheet handles its own scrolling, so hand it
/// a Column of rows rather than a ListView - unless the body IS a long list,
/// in which case pass [scrollable] false and bring your own scroller (it gets
/// a bounded height to work in).
Future<T?> showSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
String? title,
String? subtitle,
Widget? trailing,
bool dismissible = true,
bool scrollable = true,
double maxHeightFactor = 0.85,
double maxWidth = 560,
bool useRootNavigator = true,
}) {
final nav = Navigator.of(context, rootNavigator: useRootNavigator);
final themes = InheritedTheme.capture(from: context, to: nav.context);
return nav.push<T>(
_SheetRoute<T>(
themes: themes,
dismissible: dismissible,
builder: (ctx) => Sheet(
title: title,
subtitle: subtitle,
trailing: trailing,
scrollable: scrollable,
maxHeightFactor: maxHeightFactor,
maxWidth: maxWidth,
onClose: dismissible ? () => Navigator.of(ctx).maybePop() : null,
child: Builder(builder: builder),
),
),
);
}
class _SheetRoute<T> extends PopupRoute<T> {
_SheetRoute({
required this.builder,
required this.themes,
required this.dismissible,
});
final WidgetBuilder builder;
final CapturedThemes themes;
final bool dismissible;
// the drag handle moves the route by hand. controller is protected, so the
// sheet asks through here rather than grabbing it
void dragTo(double v) => controller?.value = v.clamp(0.0, 1.0);
void settle() => controller?.animateTo(1, curve: Curves.easeOutCubic);
@override
Color? get barrierColor => const Color(0x00000000);
// the scrim is painted below so it can fade with the drag, the barrier is
// just the thing that catches the tap
@override
bool get barrierDismissible => dismissible;
@override
String? get barrierLabel => "Dismiss";
@override
Duration get transitionDuration => const Duration(milliseconds: 240);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 180);
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return themes.wrap(
_SheetDragScope(
route: this,
child: Builder(builder: builder),
),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final curve = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: FadeTransition(
opacity: curve,
child: ColoredBox(color: const Color(0x99000000)),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(curve),
child: child,
),
),
],
);
}
}
// lets the handle reach the route to drive it by hand while its being dragged
// down, the same way a real sheet follows your finger rather than waiting for
// you to let go
class _SheetDragScope extends InheritedWidget {
const _SheetDragScope({required this.route, required super.child});
final _SheetRoute<dynamic> route;
static _SheetRoute<dynamic>? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_SheetDragScope>()?.route;
@override
bool updateShouldNotify(_SheetDragScope old) => old.route != route;
}
/// The sheet surface on its own, for when you want it somewhere other than a
/// modal route - pinned under a page, say. [showSheet] wraps one of these.
class Sheet extends StatefulWidget {
const Sheet({
super.key,
required this.child,
this.title,
this.subtitle,
this.trailing,
this.onClose,
this.scrollable = true,
this.maxHeightFactor = 0.85,
this.maxWidth = 560,
});
final Widget child;
final String? title;
final String? subtitle;
/// Far end of the header, before the close button. A count, a single action.
final Widget? trailing;
/// Null hides the close button (and the handle stops dismissing).
final VoidCallback? onClose;
final bool scrollable;
final double maxHeightFactor;
final double maxWidth;
@override
State<Sheet> createState() => _SheetState();
}
class _SheetState extends State<Sheet> {
double _dragged = 0;
double _height = 1;
_SheetRoute<dynamic>? get _route => _SheetDragScope.maybeOf(context);
void _dragUpdate(DragUpdateDetails d) {
if (widget.onClose == null) return;
final route = _route;
if (route == null) return;
_dragged = (_dragged + d.delta.dy).clamp(0.0, _height);
// drive the route's own controller so the scrim fades with the finger
route.dragTo(1 - _dragged / _height);
}
void _dragEnd(DragEndDetails d) {
if (widget.onClose == null) return;
final route = _route;
if (route == null) return;
final v = d.primaryVelocity ?? 0;
final gone = v > 700 || _dragged > _height * 0.35;
_dragged = 0;
if (gone) {
widget.onClose!();
} else {
route.settle();
}
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final d = theme.density;
final mq = MediaQuery.of(context);
final radius = Radius.circular(theme.panelRadius + 4);
final header = GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: _dragUpdate,
onVerticalDragEnd: _dragEnd,
excludeFromSemantics: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: d.gapSm, bottom: d.gapXs),
child: Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: cs.mutedForeground.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
),
if (widget.title != null || widget.onClose != null)
Padding(
padding: EdgeInsets.fromLTRB(
d.containerPadding,
0,
d.gapMd,
d.gapSm,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.title != null)
Semantics(
header: true,
child: Text(
widget.title!,
style: theme.typography.semiBold.copyWith(
fontSize: d.textSm,
),
),
),
if (widget.subtitle != null) ...[
SizedBox(height: d.gapXxs),
Text(
widget.subtitle!,
style: TextStyle(
fontSize: d.textXxs,
color: cs.mutedForeground,
),
),
],
],
),
),
if (widget.trailing != null) ...[
SizedBox(width: d.gapSm),
widget.trailing!,
],
if (widget.onClose != null) ...[
SizedBox(width: d.gapXs),
IconButton.ghost(
semanticLabel: "Close",
onPressed: widget.onClose,
icon: const Icon(LucideIcons.x),
),
],
],
),
),
Container(height: 1, color: cs.divider),
],
),
);
// keyboard pushes the whole sheet up, not just the bit with the field in
final maxH = (mq.size.height - mq.viewInsets.bottom - mq.padding.top) *
widget.maxHeightFactor;
Widget body = widget.child;
if (widget.scrollable) {
body = SingleChildScrollView(
padding: EdgeInsets.only(bottom: mq.padding.bottom + d.gapMd),
child: body,
);
} else {
body = Padding(
padding: EdgeInsets.only(bottom: mq.padding.bottom),
child: body,
);
}
return Padding(
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.maxWidth, maxHeight: maxH),
child: LayoutBuilder(
builder: (context, c) {
_height = c.maxHeight;
return Semantics(
scopesRoute: true,
namesRoute: widget.title != null,
label: widget.title,
explicitChildNodes: true,
child: Container(
decoration: BoxDecoration(
color: cs.card,
borderRadius: BorderRadius.vertical(top: radius),
border: Border(
top: BorderSide(color: cs.propertiesSectionBorder),
left: BorderSide(color: cs.propertiesSectionBorder),
right: BorderSide(color: cs.propertiesSectionBorder),
),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
// the list takes what the header leaves and no more
widget.scrollable
? Flexible(child: body)
: Expanded(child: body),
],
),
),
);
},
),
),
);
}
}
/// A tappable row for a sheet or any other plain list - icon, a label, an
/// optional muted line under it and something on the end.
///
/// The list row the kit didnt have. A ghost Button stretched full width got
/// close, but it centres a single line and has nowhere for the second one.
class SheetRow extends StatefulWidget {
const SheetRow({
super.key,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onPressed,
this.selected = false,
this.destructive = false,
});
final Widget title;
final Widget? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onPressed;
/// Tinted like the rail marks the page youre on.
final bool selected;
/// Title and icon in the destructive colour.
final bool destructive;
@override
State<SheetRow> createState() => _SheetRowState();
}
class _SheetRowState extends State<SheetRow> {
bool _hover = false;
bool _down = false;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final d = theme.density;
final enabled = widget.onPressed != null;
Color? fill;
if (widget.selected) fill = cs.secondary;
if (enabled && (_hover || _down)) fill = cs.rowHovered;
final fg = widget.destructive ? cs.destructive : cs.foreground;
Widget row = Container(
constraints: BoxConstraints(minHeight: d.controlHeight + d.gapSm),
padding: EdgeInsets.symmetric(
horizontal: d.containerPadding,
vertical: d.gapSm,
),
color: fill,
child: Row(
children: [
if (widget.leading != null) ...[
IconTheme.merge(
data: IconThemeData(
color: widget.destructive ? cs.destructive : cs.mutedForeground,
),
child: widget.leading!,
),
SizedBox(width: d.gapMd),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
DefaultTextStyle.merge(
style: TextStyle(color: fg),
child: widget.title,
),
if (widget.subtitle != null) ...[
SizedBox(height: d.gapXxs),
DefaultTextStyle.merge(
style: TextStyle(
fontSize: d.textXxs,
color: cs.mutedForeground,
),
child: widget.subtitle!,
),
],
],
),
),
if (widget.trailing != null) ...[
SizedBox(width: d.gapSm),
IconTheme.merge(
data: IconThemeData(color: cs.mutedForeground),
child: widget.trailing!,
),
],
],
),
);
if (!enabled) return row;
return Semantics(
button: true,
selected: widget.selected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapCancel: () => setState(() => _down = false),
onTapUp: (_) => setState(() => _down = false),
onTap: widget.onPressed,
child: row,
),
),
);
}
}
+907
View File
@@ -0,0 +1,907 @@
// GarageUI — surfaces + basic layout widgets.
//
// hand rolled replacements for the shadcn surface bits we lean on:
// OutlinedContainer, Card, SurfaceCard, IconContainer, Divider,
// VerticalDivider, Gap, Basic and Label.
//
// styling here is copied straight out of shadcn_flutter 0.0.52 so the app
// keeps looking exactly the same. we still read the theme + a couple of tiny
// pure helpers (styleValue, subtractByBorder, scaleAlpha, the density padding
// resolver and the .small()/.muted() text modifiers) from shadcn during the
// migration — those imports get repointed later.
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
// hide the classes we redefine so our own definitions win. everything else
// (Theme, styleValue, the text extensions, SurfaceBlur, density helpers...)
// still comes through unprefixed.
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
// ---------------------------------------------------------------------------
// Gap
// ---------------------------------------------------------------------------
/// A widget that takes a fixed amount of space in the direction of its parent.
///
/// Only works as a descendant of a [Row], [Column] or [Flex] (or a
/// [Scrollable]). Reproduced from the `gap` package so we don't rely on it
/// coming in transitively through shadcn.
/// A step on the theme's gap scale. See [Density] for the values.
enum GapStep {
xxs,
xs,
sm,
md,
lg,
xl,
xxl;
double of(Density density) => switch (this) {
GapStep.xxs => density.gapXxs,
GapStep.xs => density.gapXs,
GapStep.sm => density.gapSm,
GapStep.md => density.gapMd,
GapStep.lg => density.gapLg,
GapStep.xl => density.gapXl,
GapStep.xxl => density.gapXxl,
};
}
class Gap extends StatelessWidget {
const Gap(
double this.mainAxisExtent, {
super.key,
this.crossAxisExtent,
this.color,
}) : step = null,
assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
assert(crossAxisExtent == null || crossAxisExtent >= 0);
// The scale constructors. These resolve their extent at build time, which is
// the whole point - a call site stays `const` and still moves when the
// density does. Handing `Gap` a number from `theme.density` at the call site
// would work too, but it costs the const and drags a GarageTheme.of() into
// every build method that happens to contain a gap.
const Gap.xxs({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xxs;
const Gap.xs({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xs;
const Gap.sm({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.sm;
const Gap.md({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.md;
const Gap.lg({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.lg;
const Gap.xl({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xl;
const Gap.xxl({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xxl;
const Gap.expand(double mainAxisExtent, {Key? key, Color? color})
: this(
mainAxisExtent,
key: key,
crossAxisExtent: double.infinity,
color: color,
);
/// space taken along the parent's main axis. null when [step] is set, in
/// which case the extent comes off the theme instead.
final double? mainAxisExtent;
/// which step of the density's gap scale to use. null for a literal [Gap].
final GapStep? step;
/// space taken along the cross axis. null = match the parent constraints.
final double? crossAxisExtent;
/// optional fill colour.
final Color? color;
@override
Widget build(BuildContext context) {
final scrollableState = Scrollable.maybeOf(context);
final AxisDirection? axisDirection = scrollableState?.axisDirection;
final Axis? fallbackDirection = axisDirection == null
? null
: axisDirectionToAxis(axisDirection);
final extent = mainAxisExtent ?? step!.of(GarageTheme.of(context).density);
return _RawGap(
extent,
crossAxisExtent: crossAxisExtent,
color: color,
fallbackDirection: fallbackDirection,
);
}
}
class _RawGap extends LeafRenderObjectWidget {
const _RawGap(
this.mainAxisExtent, {
this.crossAxisExtent,
this.color,
this.fallbackDirection,
}) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
assert(crossAxisExtent == null || crossAxisExtent >= 0);
final double mainAxisExtent;
final double? crossAxisExtent;
final Color? color;
final Axis? fallbackDirection;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderGap(
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent ?? 0,
color: color,
fallbackDirection: fallbackDirection,
);
}
@override
void updateRenderObject(BuildContext context, _RenderGap renderObject) {
renderObject
..mainAxisExtent = mainAxisExtent
..crossAxisExtent = crossAxisExtent ?? 0
..color = color
..fallbackDirection = fallbackDirection;
}
}
class _RenderGap extends RenderBox {
_RenderGap({
required double mainAxisExtent,
double? crossAxisExtent,
Axis? fallbackDirection,
Color? color,
}) : _mainAxisExtent = mainAxisExtent,
_crossAxisExtent = crossAxisExtent,
_color = color,
_fallbackDirection = fallbackDirection;
double get mainAxisExtent => _mainAxisExtent;
double _mainAxisExtent;
set mainAxisExtent(double value) {
if (_mainAxisExtent != value) {
_mainAxisExtent = value;
markNeedsLayout();
}
}
double? get crossAxisExtent => _crossAxisExtent;
double? _crossAxisExtent;
set crossAxisExtent(double? value) {
if (_crossAxisExtent != value) {
_crossAxisExtent = value;
markNeedsLayout();
}
}
Axis? get fallbackDirection => _fallbackDirection;
Axis? _fallbackDirection;
set fallbackDirection(Axis? value) {
if (_fallbackDirection != value) {
_fallbackDirection = value;
markNeedsLayout();
}
}
Axis? get _direction {
final parentNode = parent;
if (parentNode is RenderFlex) {
return parentNode.direction;
} else {
return fallbackDirection;
}
}
Color? get color => _color;
Color? _color;
set color(Color? value) {
if (_color != value) {
_color = value;
markNeedsPaint();
}
}
@override
double computeMinIntrinsicWidth(double height) {
return _computeIntrinsicExtent(
Axis.horizontal,
() => super.computeMinIntrinsicWidth(height),
)!;
}
@override
double computeMaxIntrinsicWidth(double height) {
return _computeIntrinsicExtent(
Axis.horizontal,
() => super.computeMaxIntrinsicWidth(height),
)!;
}
@override
double computeMinIntrinsicHeight(double width) {
return _computeIntrinsicExtent(
Axis.vertical,
() => super.computeMinIntrinsicHeight(width),
)!;
}
@override
double computeMaxIntrinsicHeight(double width) {
return _computeIntrinsicExtent(
Axis.vertical,
() => super.computeMaxIntrinsicHeight(width),
)!;
}
double? _computeIntrinsicExtent(Axis axis, double Function() compute) {
final Axis? direction = _direction;
if (direction == axis) {
return _mainAxisExtent;
} else {
if (_crossAxisExtent!.isFinite) {
return _crossAxisExtent;
} else {
return compute();
}
}
}
@override
Size computeDryLayout(BoxConstraints constraints) {
final Axis? direction = _direction;
if (direction != null) {
if (direction == Axis.horizontal) {
return constraints.constrain(Size(mainAxisExtent, crossAxisExtent!));
} else {
return constraints.constrain(Size(crossAxisExtent!, mainAxisExtent));
}
}
// No Flex parent and no scrollable to borrow a direction from. This used
// to throw, which took the whole enclosing subtree down at layout time
// with nothing to catch it beforehand - a Gap inside an AnimatedSize was
// enough to destroy a page. A vertical gap is the overwhelmingly common
// intent, so fall back to it and complain in debug instead of exploding
// in release.
assert(() {
FlutterError.reportError(
FlutterErrorDetails(
exception: FlutterError(
"Gap has no axis to size along.\n"
"It isn't a direct child of a Flex (Row/Column) and there's no "
"Scrollable above it, so it fell back to a vertical gap of "
"$mainAxisExtent. Give it a Flex parent, or use SizedBox if you "
"meant a fixed box.",
),
library: "garage_ui",
),
);
return true;
}());
return constraints.constrain(Size(crossAxisExtent ?? 0, mainAxisExtent));
}
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
void paint(PaintingContext context, Offset offset) {
if (color != null) {
final Paint paint = Paint()..color = color!;
context.canvas.drawRect(offset & size, paint);
}
}
}
// ---------------------------------------------------------------------------
// OutlinedContainer
// ---------------------------------------------------------------------------
/// A container with a border + background and optional surface blur.
///
/// This is the workhorse surface the toolbar/hud/panels sit on. Defaults come
/// from the theme: xl border radius, [ColorScheme.background] fill and
/// [ColourScheme.controlBorder] border at 1px (scaled).
class OutlinedContainer extends StatefulWidget {
const OutlinedContainer({
super.key,
required this.child,
this.borderColor,
this.backgroundColor,
this.clipBehavior = Clip.antiAlias,
this.borderRadius,
this.borderStyle,
this.borderWidth,
this.boxShadow,
this.padding,
this.surfaceOpacity,
this.surfaceBlur,
this.width,
this.height,
this.duration,
});
final Widget child;
final Color? backgroundColor;
final Color? borderColor;
final Clip clipBehavior;
final BorderRadiusGeometry? borderRadius;
final BorderStyle? borderStyle;
final double? borderWidth;
final List<BoxShadow>? boxShadow;
final EdgeInsetsGeometry? padding;
final double? surfaceOpacity;
final double? surfaceBlur;
final double? width;
final double? height;
final Duration? duration;
@override
State<OutlinedContainer> createState() => _OutlinedContainerState();
}
class _OutlinedContainerState extends State<OutlinedContainer> {
final GlobalKey _mainContainerKey = GlobalKey();
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
var borderRadius = (widget.borderRadius ?? theme.borderRadiusXl).resolve(
Directionality.of(context),
);
var backgroundColor =
widget.backgroundColor ?? theme.colorScheme.background;
final surfaceOpacity = widget.surfaceOpacity;
if (surfaceOpacity != null) {
backgroundColor = backgroundColor.scaleAlpha(surfaceOpacity);
}
// controlBorder, not muted. muted is a FILL - it was doing stroke duty
// here only because it happened to be the darkest thing on offer, and
// it derives BELOW the ground, so the outline on the workhorse surface
// was reading as a shadow rather than an edge.
final borderColor = widget.borderColor ?? theme.colorScheme.controlBorder;
final borderWidth = widget.borderWidth ?? (1 * scaling);
final borderStyle = widget.borderStyle ?? BorderStyle.solid;
final boxShadow = widget.boxShadow ?? const <BoxShadow>[];
final padding = widget.padding ?? EdgeInsets.zero;
final surfaceBlur = widget.surfaceBlur;
Widget childWidget = AnimatedContainer(
duration: widget.duration ?? Duration.zero,
key: _mainContainerKey,
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(
color: borderColor,
width: borderWidth,
style: borderStyle,
),
borderRadius: borderRadius,
boxShadow: boxShadow,
),
child: AnimatedContainer(
duration: widget.duration ?? Duration.zero,
clipBehavior: widget.clipBehavior,
decoration: BoxDecoration(
borderRadius: subtractByBorder(borderRadius, borderWidth),
),
child: DensityContainerPadding(padding: padding, child: widget.child),
),
);
if (surfaceBlur != null && surfaceBlur > 0) {
childWidget = SurfaceBlur(
surfaceBlur: surfaceBlur,
borderRadius: subtractByBorder(borderRadius, borderWidth),
child: childWidget,
);
}
return childWidget;
}
}
// ---------------------------------------------------------------------------
// Card / SurfaceCard
// ---------------------------------------------------------------------------
/// A card surface — basically an [OutlinedContainer] with card colours and a
/// density aware padding. Merges a [ColourScheme.foreground] default text
/// colour over its child.
class Card extends StatelessWidget {
const Card({
super.key,
required this.child,
this.padding,
this.filled,
this.fillColor,
this.borderRadius,
this.clipBehavior,
this.borderColor,
this.borderWidth,
this.boxShadow,
this.surfaceOpacity,
this.surfaceBlur,
this.duration,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final bool? filled;
final Color? fillColor;
final BorderRadiusGeometry? borderRadius;
final Color? borderColor;
final double? borderWidth;
final Clip? clipBehavior;
final List<BoxShadow>? boxShadow;
final double? surfaceOpacity;
final double? surfaceBlur;
final Duration? duration;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final densityContainerPadding = theme.density.containerPadding;
final padding = this.padding ?? EdgeInsets.all(densityContainerPadding);
final filled = this.filled ?? false;
final fillColor = this.fillColor ?? theme.colorScheme.border;
final clipBehavior = this.clipBehavior ?? Clip.none;
return OutlinedContainer(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
borderWidth: borderWidth,
borderColor: borderColor,
backgroundColor: filled ? fillColor : theme.colorScheme.card,
boxShadow: boxShadow,
padding: padding,
surfaceOpacity: surfaceOpacity,
surfaceBlur: surfaceBlur,
duration: duration,
child: DefaultTextStyle.merge(
style: TextStyle(color: theme.colorScheme.foreground),
child: child,
),
);
}
}
/// [Card] variant that picks up the theme's surface blur/opacity. When drawn
/// inside a sheet overlay it collapses to just padding (no double surface).
class SurfaceCard extends StatelessWidget {
const SurfaceCard({
super.key,
required this.child,
this.padding,
this.filled,
this.fillColor,
this.borderRadius,
this.clipBehavior,
this.borderColor,
this.borderWidth,
this.boxShadow,
this.surfaceOpacity,
this.surfaceBlur,
this.duration,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final bool? filled;
final Color? fillColor;
final BorderRadiusGeometry? borderRadius;
final Color? borderColor;
final double? borderWidth;
final Clip? clipBehavior;
final List<BoxShadow>? boxShadow;
final double? surfaceOpacity;
final double? surfaceBlur;
final Duration? duration;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final isSheetOverlay = SheetOverlayHandler.isSheetOverlay(context);
final densityContainerPadding = theme.density.containerPadding;
if (isSheetOverlay) {
final padding = this.padding ?? EdgeInsets.all(densityContainerPadding);
return Padding(padding: padding, child: child);
}
return Card(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
borderWidth: borderWidth,
borderColor: borderColor,
filled: filled,
fillColor: fillColor,
boxShadow: boxShadow,
padding: padding,
surfaceOpacity: surfaceOpacity ?? theme.surfaceOpacity,
surfaceBlur: surfaceBlur ?? theme.surfaceBlur,
duration: duration,
child: child,
);
}
}
// ---------------------------------------------------------------------------
// IconContainer
// ---------------------------------------------------------------------------
/// A little rounded chip around an icon. Defaults to the primary colour with
/// a primaryForeground tint on the icon.
class IconContainer extends StatelessWidget {
const IconContainer({
super.key,
required this.icon,
this.padding,
this.borderRadius,
this.backgroundColor,
this.iconColor,
});
final Widget icon;
final EdgeInsetsGeometry? padding;
final BorderRadius? borderRadius;
final Color? backgroundColor;
final Color? iconColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
// padXs used to be a quarter-ish of the container base, resolved by density.
final xs = theme.density.containerPadding * 0.25;
return Container(
padding: padding ?? EdgeInsets.all(xs),
decoration: BoxDecoration(
color: backgroundColor ?? theme.colorScheme.primary,
borderRadius: borderRadius ?? theme.borderRadiusMd,
),
child: IconTheme(
data: IconThemeData(
color: iconColor ?? theme.colorScheme.primaryForeground,
),
child: icon,
),
);
}
}
// ---------------------------------------------------------------------------
// Divider / VerticalDivider
// ---------------------------------------------------------------------------
/// A thin horizontal rule. Colour defaults to the scheme divider, 1px thick.
///
/// note: shadcn's Divider can host a centered child label — nothing in the app
/// uses that so it's dropped here.
class Divider extends StatelessWidget implements PreferredSizeWidget {
const Divider({
super.key,
this.color,
this.height,
this.thickness,
this.indent,
this.endIndent,
});
final Color? color;
final double? height;
final double? thickness;
final double? indent;
final double? endIndent;
@override
Size get preferredSize => Size(0, height ?? 1);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final color = this.color ?? theme.colorScheme.divider;
final thickness = this.thickness ?? 1.0;
final height = this.height ?? thickness;
final indent = this.indent ?? 0.0;
final endIndent = this.endIndent ?? 0.0;
return SizedBox(
height: height,
width: double.infinity,
child: CustomPaint(
painter: _DividerPainter(
color: color,
thickness: thickness,
indent: indent,
endIndent: endIndent,
),
),
);
}
}
/// vertical counterpart of [Divider].
class VerticalDivider extends StatelessWidget implements PreferredSizeWidget {
const VerticalDivider({
super.key,
this.color,
this.width,
this.thickness,
this.indent,
this.endIndent,
});
final Color? color;
final double? width;
final double? thickness;
final double? indent;
final double? endIndent;
@override
Size get preferredSize => Size(width ?? 1, 0);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox(
width: width ?? 1,
height: double.infinity,
child: CustomPaint(
painter: _VerticalDividerPainter(
color: color ?? theme.colorScheme.divider,
thickness: thickness ?? 1,
indent: indent ?? 0,
endIndent: endIndent ?? 0,
),
),
);
}
}
class _DividerPainter extends CustomPainter {
_DividerPainter({
required this.color,
required this.thickness,
required this.indent,
required this.endIndent,
});
final Color color;
final double thickness;
final double indent;
final double endIndent;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = thickness
..strokeCap = StrokeCap.square;
final start = Offset(indent, size.height / 2);
final end = Offset(size.width - endIndent, size.height / 2);
canvas.drawLine(start, end, paint);
}
@override
bool shouldRepaint(covariant _DividerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.thickness != thickness ||
oldDelegate.indent != indent ||
oldDelegate.endIndent != endIndent;
}
}
class _VerticalDividerPainter extends CustomPainter {
_VerticalDividerPainter({
required this.color,
required this.thickness,
required this.indent,
required this.endIndent,
});
final Color color;
final double thickness;
final double indent;
final double endIndent;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = thickness
..strokeCap = StrokeCap.square;
final start = Offset(size.width / 2, indent);
final end = Offset(size.width / 2, size.height - endIndent);
canvas.drawLine(start, end, paint);
}
@override
bool shouldRepaint(covariant _VerticalDividerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.thickness != thickness ||
oldDelegate.indent != indent ||
oldDelegate.endIndent != endIndent;
}
}
// ---------------------------------------------------------------------------
// Basic
// ---------------------------------------------------------------------------
/// The classic list-item style layout: optional leading, a title/subtitle/
/// content column, and an optional trailing. Title gets small+medium, subtitle
/// gets xSmall+muted, content gets small — matching shadcn exactly.
class Basic extends StatelessWidget {
const Basic({
super.key,
this.leading,
this.title,
this.subtitle,
this.content,
this.trailing,
this.leadingAlignment,
this.trailingAlignment,
this.titleAlignment,
this.subtitleAlignment,
this.contentAlignment,
this.contentSpacing, // 16
this.titleSpacing, // 4
this.mainAxisAlignment,
this.padding,
});
final Widget? leading;
final Widget? title;
final Widget? subtitle;
final Widget? content;
final Widget? trailing;
final AlignmentGeometry? leadingAlignment;
final AlignmentGeometry? trailingAlignment;
final AlignmentGeometry? titleAlignment;
final AlignmentGeometry? subtitleAlignment;
final AlignmentGeometry? contentAlignment;
final double? contentSpacing;
final double? titleSpacing;
final MainAxisAlignment? mainAxisAlignment;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final densityGap = theme.density.containerGap;
final densityContainerPadding = theme.density.containerPadding;
final padding = this.padding ?? EdgeInsets.zero;
final resolvedPadding = resolveEdgeInsets(padding, densityContainerPadding);
final contentSpacing = this.contentSpacing ?? densityGap * 2;
final titleSpacing = this.titleSpacing ?? densityGap * 0.5;
final leadingAlignment = this.leadingAlignment ?? Alignment.topCenter;
final trailingAlignment = this.trailingAlignment ?? Alignment.topCenter;
final titleAlignment = this.titleAlignment ?? Alignment.topLeft;
final subtitleAlignment = this.subtitleAlignment ?? Alignment.topLeft;
final contentAlignment = this.contentAlignment ?? Alignment.topLeft;
final mainAxisAlignment =
this.mainAxisAlignment ?? MainAxisAlignment.center;
return Padding(
padding: resolvedPadding,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: mainAxisAlignment,
children: [
if (leading != null)
Align(alignment: leadingAlignment, child: leading!),
if (leading != null &&
(title != null || content != null || subtitle != null))
SizedBox(width: contentSpacing),
if (title != null || content != null || subtitle != null)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: mainAxisAlignment,
children: [
if (title != null)
Align(
alignment: titleAlignment,
child: title!,
).small().medium(),
if (title != null && subtitle != null)
SizedBox(height: densityGap * 0.25),
if (subtitle != null)
Align(
alignment: subtitleAlignment,
child: subtitle!,
).xSmall().muted(),
if ((title != null || subtitle != null) &&
content != null)
SizedBox(height: titleSpacing),
if (content != null)
Align(
alignment: contentAlignment,
child: content!,
).small(),
],
),
),
if (trailing != null &&
(title != null ||
content != null ||
leading != null ||
subtitle != null))
SizedBox(width: contentSpacing),
if (trailing != null)
Align(alignment: trailingAlignment, child: trailing!),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Label
// ---------------------------------------------------------------------------
/// A label row — the main child expanded in the middle with optional leading
/// and trailing widgets, 8px (scaled) gaps between them.
class Label extends StatelessWidget {
const Label({super.key, this.leading, required this.child, this.trailing});
final Widget? leading;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
return IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (leading != null) leading!,
if (leading != null) SizedBox(width: 8 * scaling),
Expanded(child: child),
if (trailing != null) SizedBox(width: 8 * scaling),
if (trailing != null) trailing!,
],
),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "button.dart";
import "editor_chrome.dart";
import "surface.dart";
import "theme/garage_theme.dart";
/// One entry in a [TabView] - the label (and optional icon) for its header
/// button, plus the widget shown while it's selected.
class TabViewItem {
const TabViewItem({required this.label, this.icon, required this.child});
final String label;
final IconData? icon;
final Widget child;
}
/// A row of buttons across the top, one page shown below at a time.
///
/// Not a browser-style tab strip - no close buttons, no reordering, no
/// scrolling overflow. This is closer to what FL Studio calls its pattern/
/// mixer/playlist "windows": conceptually separate workspaces that happen
/// to live as pages in the same frame instead of actual floating windows.
///
/// Every tab's widget stays mounted the whole time (via [IndexedStack]),
/// just hidden when it isn't selected, so switching tabs doesn't reset
/// whatever state the page underneath is holding onto - scroll position,
/// text fields, a half-finished drag, etc.
///
/// Controlled, not stateful - the caller owns [selectedIndex] and finds
/// out about taps via [onSelected], same shape as everything else in
/// GarageUI with a "which one is picked" concept (see Select).
class TabView extends StatelessWidget {
const TabView({
super.key,
required this.tabs,
required this.selectedIndex,
required this.onSelected,
this.onClosed,
});
final List<TabViewItem> tabs;
final int selectedIndex;
final ValueChanged<int> onSelected;
/// Called with a tab's index when its close (x) button is pressed - only
/// the active tab gets one. Leave null and no close button shows at all,
/// same "nothing happens unless the caller wired it up" deal as everywhere
/// else here - TabView itself doesn't know what "closing" a tab even means
/// for the caller (remove it? just hide it? ask first?), it just reports it
final ValueChanged<int>? onClosed;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(context),
Expanded(
child: IndexedStack(
index: selectedIndex,
children: [for (final tab in tabs) tab.child],
),
),
],
);
}
Widget _header(BuildContext context) {
final theme = GarageTheme.of(context);
// tried wrapping ChromeBar in an outer bordered DecoratedBox for the
// divider first - didnt work, ChromeBar paints its own opaque chrome-
// coloured background as a child ON TOP of that outer decoration (same
// bounds and everything) so the border stroke just gets painted over
// and never shows. a real 1px sibling below it actually renders
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ChromeBar(
padding: const EdgeInsets.fromLTRB(8, 4, 6, 4),
child: Row(
children: [
for (var i = 0; i < tabs.length; i++) ...[
if (i > 0) const Gap(4),
_tabButton(context, i),
],
],
),
),
Container(height: 1, color: theme.colorScheme.divider),
],
);
}
Widget _tabButton(BuildContext context, int index) {
final theme = GarageTheme.of(context);
final tab = tabs[index];
final selected = index == selectedIndex;
// Button has no automatic icon theming of its own (it never wraps its
// content in an IconTheme) - has to be coloured by hand here to match
// whichever variant its sitting on, or it just comes out whatever the
// ambient default happens to be, not the button's actual foreground.
// ghost's foreground is just colorScheme.foreground, not a "ghost"
// specific token - see _buttonGhostIconTheme
final iconColor = selected
? theme.colorScheme.primaryForeground
: theme.colorScheme.foreground;
final leading = tab.icon == null
? null
: Icon(tab.icon, size: theme.iconTheme.small.size, color: iconColor);
// compact density, not the default - this header bar is the same
// slim ChromeBar height as the app's own menu bar (which explicitly
// opts into ControlDensity.compact for the same reason), a normal-
// density button doesn't fit in that height without getting squished.
// active tab reads as primary, everything else fades into ghost so it
// doesnt fight the active one for attention
final tabButton = selected
? Button.primary(
style: const ButtonStyle.primary(density: ControlDensity.compact),
leading: leading,
onPressed: () => onSelected(index),
child: Text(tab.label),
)
: Button.ghost(
style: const ButtonStyle.ghost(density: ControlDensity.compact),
leading: leading,
onPressed: () => onSelected(index),
child: Text(tab.label),
);
// close button only shows up on the active tab, and only if the caller
// actually wants one - grouped onto the tab button via ButtonGroup so
// the two share one pill shape instead of looking like two seperate
// buttons bolted together
if (!selected || onClosed == null) return tabButton;
return ButtonGroup.horizontal(
children: [
tabButton,
IconButton.secondary(
// same deal as the tab buttons above - Button never themes its
// child's colour on its own, icon widget has to bring its own
icon: Icon(
LucideIcons.x,
size: theme.iconTheme.small.size,
color: theme.colorScheme.secondaryForeground,
),
density: ControlDensity.compact,
onPressed: () => onClosed!(index),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export "theme/garage_theme.dart";
export "theme/theme_data.dart";
export "theme/colour_scheme.dart";
export "theme/typography.dart";
export "theme/support.dart";
export "theme/schemes.dart";
+562
View File
@@ -0,0 +1,562 @@
import "dart:math" as math;
import "dart:ui" show Color, lerpDouble;
import "package:flutter/painting.dart" show HSLColor;
import "package:flutter/widgets.dart" show Brightness;
// The apps own colour scheme. Used to be a subclass of shadcns ColorScheme which
// meant it kept getting flattened back to a plain base scheme every frame by the
// theme lerp - now its just our own first class type, nothing above it.
//
// Holds the usual semantic slots (background/foreground/primary/...) plus the
// app specific ones (chrome, panel borders, the control family) as equals.
//
// ── On the names ──────────────────────────────────────────────────────────
// The control family used to be called input*. That was a lie measured
// against its own call sites: of the eighteen reads of `inputBorder` exactly
// one was a text field, and the rest were outline buttons, selects, date
// inputs, checkboxes, radios, the colour swatch and toast. Anything with a
// stroke around it took the "input" token because that was the only stroke
// on offer. They're control* now, which is what they always were.
//
// `surfaceSunken` has the same history from the other end: it was
// explorerRowEven, a zebra stripe, and every consumer bar one was using it as
// "one step below the ground" for a whole pane. It's a rung on the ladder,
// not a row colour, so it gets a rung's name.
class ColourScheme {
const ColourScheme({
required this.brightness,
required this.background,
required this.foreground,
required this.card,
required this.popover,
required this.popoverBorder,
required this.tooltipBackground,
required this.tooltipBorder,
required this.primary,
required this.primaryHovered,
required this.primaryForeground,
required this.secondary,
required this.secondaryHovered,
required this.secondaryForeground,
required this.muted,
required this.mutedForeground,
required this.destructive,
required this.border,
required this.divider,
required this.surfaceSunken,
required this.controlFill,
required this.controlFillHovered,
required this.controlFillFocused,
required this.controlBorder,
required this.switchTrackInactive,
required this.ring,
required this.chart1,
required this.chart2,
required this.chart3,
required this.chart4,
required this.chart5,
required this.chrome,
required this.panelBorder,
required this.panelBorderHighlighted,
required this.rowHovered,
required this.rowText,
required this.popoverItemHovered,
required this.propertiesSectionBorder,
});
/// Builds a complete scheme from the handful of colours an app actually
/// chooses, deriving the rest.
///
/// ── How the derivation works ──────────────────────────────────────────
///
/// This used to be a table of HSL lightness offsets. HSL lightness is not
/// perceptually uniform and, worse, an offset that runs off the end of the
/// scale was *reflected*: it kept its size and lost only its direction. On
/// a ground already near the floor that turned "recess this by twelve" into
/// "raise it by twelve", and A&As carbon ended up with a canvas backdrop
/// LIGHTER than the paper sat in front of it.
///
/// Measuring the ten hand authored schemes in CIE L* showed what they
/// actually have in common, and its four rules rather than one table:
///
/// 1. Raised rungs are an absolute L* step off the ground. Zinc and carbon
/// agree here to within 1-3 points despite sitting sixteen points apart,
/// so this part was never the problem.
///
/// 2. Recessed rungs are the same idea until the ground runs out of room
/// underneath, and then they all squeeze by the same factor. Zinc has
/// 19.9 L* of basement and spends 12.6 of it on chrome. Carbon has 3.6
/// and spends all of it. Squeezing keeps their ORDER, which is the thing
/// that actually matters - reflecting destroyed it.
///
/// 3. A stroke is measured against the surface it outlines, not against the
/// ground. Every scheme puts its popover border about ten points over
/// its popover; none of them puts it ten points over the background.
/// (This is the fix that landed for propertiesSectionBorder alone in
/// 38a206f, generalised - it was never a one slot problem.)
///
/// 4. Text is a fraction of the background->foreground span, not an
/// absolute shift. mutedForeground is 60.7% of the way in zinc and
/// 60.7% in carbon, to one decimal, on spans of 71 and 92 points.
///
/// Anything you want to pin exactly is still an argument - every derived
/// slot has an override. Hand authored schemes keep using the const
/// constructor and are untouched.
factory ColourScheme.derive({
required Brightness brightness,
required Color background,
required Color foreground,
required Color primary,
Color? primaryForeground,
Color? destructive,
Color? ring,
// --- escape hatches: pin any derived slot ---
Color? chrome,
Color? card,
Color? popover,
Color? muted,
Color? tooltipBackground,
Color? secondary,
Color? border,
Color? surfaceSunken,
Color? controlFill,
Color? controlFillHovered,
Color? controlFillFocused,
Color? controlBorder,
Color? switchTrackInactive,
Color? mutedForeground,
Color? rowText,
Color? chart1,
Color? chart2,
Color? chart3,
Color? chart4,
Color? chart5,
}) {
final groundL = _lstar(background);
final span = _lstar(foreground) - groundL;
// How much of what the recessed rungs WANT this ground can actually give
// them. chrome is the deepest slot the shared scheme has (the canvas
// backdrop goes deeper, but thats an app side slot now), so it sets the
// scale: if theres room for it nothing squeezes at all.
final basement = brightness == Brightness.dark
? groundL
: math.max(groundL, _deepestSink);
final squeeze = basement >= _deepestSink ? 1.0 : basement / _deepestSink;
Color raise(double points) => _atLstar(background, groundL + points);
Color sink(double points) =>
_atLstar(background, groundL - points * squeeze);
// a stroke sits N points off the surface it outlines. reflects if theres
// no headroom that way - a border that cant get lighter than its own fill
// gets darker by the same amount, which is what youd have picked anyway.
Color edge(Color surface, double points) {
final l = _lstar(surface);
final wanted = l + points;
return _atLstar(
surface,
(wanted > 100 || wanted < 0) ? l - points : wanted,
);
}
Color text(double fraction) =>
_atLstar(foreground, groundL + span * fraction);
final resolvedChrome = chrome ?? sink(12.6);
final resolvedCard = card ?? raise(5.9);
final resolvedPopover = popover ?? sink(11.6);
final resolvedMuted = muted ?? sink(9.1);
final resolvedFill = controlFill ?? sink(9.1);
final resolvedTooltip = tooltipBackground ?? sink(9.1);
return ColourScheme(
brightness: brightness,
background: background,
foreground: foreground,
card: resolvedCard,
popover: resolvedPopover,
popoverBorder: edge(resolvedPopover, 9.9),
tooltipBackground: resolvedTooltip,
tooltipBorder: edge(resolvedTooltip, 9.1),
primary: primary,
// +11 L*, CLAMPED rather than reflected. A stroke that cant get lighter
// than its surface has to go the other way or it disappears, but a hover
// has no such problem - it just wants to be brighter, and a near white
// primary should hover to white, not turn round and dim to grey.
primaryHovered: _atLstar(primary, math.min(100, _lstar(primary) + 11.0)),
primaryForeground: primaryForeground ?? _contrastColor(primary),
secondary: secondary ?? raise(16.3),
secondaryHovered: raise(23.9),
// nine of the ten hand authored schemes put this exactly on foreground.
secondaryForeground: foreground,
muted: resolvedMuted,
mutedForeground: mutedForeground ?? text(0.607),
destructive:
destructive ??
(brightness == Brightness.dark
? const Color(0xffa9575f)
: const Color(0xffb3474f)),
// both reference schemes put the outermost line on the floor with
// chrome - its the gutter between panels, not a stroke on a surface.
border: border ?? resolvedChrome,
// 4.1 points off the ground is a line you have to go looking for. The
// shell has drawn its own rules with panelBorder (9.6) since forever
// and nobody has ever called those loud, so a divider sits just under
// that - visible, still subordinate to the edge of a panel.
divider: edge(background, 9.0),
surfaceSunken: surfaceSunken ?? sink(3.8),
controlFill: resolvedFill,
controlFillHovered: controlFillHovered ?? sink(6.5),
controlFillFocused: controlFillFocused ?? sink(11.0),
controlBorder: controlBorder ?? edge(resolvedFill, 11.8),
// the off state track of a Switch. was called `input`, which told you
// nothing and collided with muted in every derived scheme.
switchTrackInactive: switchTrackInactive ?? resolvedMuted,
ring: ring ?? _brightenForSelectionRing(primary),
chart1: chart1 ?? const Color(0xffff3352),
chart2: chart2 ?? const Color(0xff8bdc00),
chart3: chart3 ?? const Color(0xff2890ff),
chart4: chart4 ?? const Color(0xffedba18),
chart5: chart5 ?? const Color(0xffed5700),
chrome: resolvedChrome,
panelBorder: edge(background, 9.6),
panelBorderHighlighted: raise(20.8),
rowHovered: raise(10.0),
rowText: rowText ?? text(0.814),
popoverItemHovered: raise(8.0),
// +6.2 over `card`, which is what a properties section is filled with -
// NOT off the ground. Measured off the background it sat under a point
// above its own fill and vanished into it.
propertiesSectionBorder: edge(resolvedCard, 6.2),
);
}
final Brightness brightness;
final Color background;
final Color foreground;
/// Raised surface. Cards, and the fill behind a properties section.
final Color card;
final Color popover;
final Color popoverBorder;
// tooltips get their own pair rather than riding popover's - they sit on
// top of everything and want more contrast than a panel-level surface.
final Color tooltipBackground;
final Color tooltipBorder;
final Color primary;
final Color primaryHovered;
final Color primaryForeground;
final Color secondary;
// secondary fill, hovered. used to be one hardcoded grey duplicated in
// button.dart and select.dart, which meant the crimson/light schemes both
// hovered to the same dark grey.
final Color secondaryHovered;
final Color secondaryForeground;
final Color muted;
final Color mutedForeground;
final Color destructive;
final Color border;
final Color divider;
/// One step below the ground. Side panes, list backgrounds, anything
/// recessed into the surface its sat on rather than lifted off it.
final Color surfaceSunken;
// The control family: the fill and stroke every control shares - text
// fields, selects, date inputs, outline and ghost buttons, checkboxes,
// radios, the colour swatch. NOT text-field-only, whatever the old names
// claimed.
final Color controlFill;
final Color controlFillHovered;
final Color controlFillFocused;
final Color controlBorder;
/// A Switch's track while its off. Its own slot because nothing else wants
/// this colour and it used to squat on `input`.
final Color switchTrackInactive;
final Color ring;
final Color chart1;
final Color chart2;
final Color chart3;
final Color chart4;
final Color chart5;
// header/footer chrome, nudged off background so it reads as chrome.
final Color chrome;
// borders of a content panel. The panel FILL is just `background` - all ten
// hand authored schemes had them identical, so theres no slot for it.
final Color panelBorder;
final Color panelBorderHighlighted;
/// Hovered row, in a list or a menu. One slot: menuItemHovered and
/// explorerRowHovered were the same colour in all ten schemes.
final Color rowHovered;
/// Resting label/icon colour for a row. Was explorerRowText, and
/// propertiesSectionLabel was the same colour in all ten schemes.
final Color rowText;
// Interactive rows inside popovers. Kept separate from row hover while the
// two interaction colours are being evaluated.
final Color popoverItemHovered;
/// Outline of an object properties section. Measured off `card`, which is
/// what fills one.
final Color propertiesSectionBorder;
ColourScheme copyWith({
Brightness? brightness,
Color? background,
Color? foreground,
Color? card,
Color? popover,
Color? popoverBorder,
Color? tooltipBackground,
Color? tooltipBorder,
Color? primary,
Color? primaryHovered,
Color? primaryForeground,
Color? secondary,
Color? secondaryHovered,
Color? secondaryForeground,
Color? muted,
Color? mutedForeground,
Color? destructive,
Color? border,
Color? divider,
Color? surfaceSunken,
Color? controlFill,
Color? controlFillHovered,
Color? controlFillFocused,
Color? controlBorder,
Color? switchTrackInactive,
Color? ring,
Color? chart1,
Color? chart2,
Color? chart3,
Color? chart4,
Color? chart5,
Color? chrome,
Color? panelBorder,
Color? panelBorderHighlighted,
Color? rowHovered,
Color? rowText,
Color? popoverItemHovered,
Color? propertiesSectionBorder,
}) {
return ColourScheme(
brightness: brightness ?? this.brightness,
background: background ?? this.background,
foreground: foreground ?? this.foreground,
card: card ?? this.card,
popover: popover ?? this.popover,
popoverBorder: popoverBorder ?? this.popoverBorder,
tooltipBackground: tooltipBackground ?? this.tooltipBackground,
tooltipBorder: tooltipBorder ?? this.tooltipBorder,
primary: primary ?? this.primary,
primaryHovered: primaryHovered ?? this.primaryHovered,
primaryForeground: primaryForeground ?? this.primaryForeground,
secondary: secondary ?? this.secondary,
secondaryHovered: secondaryHovered ?? this.secondaryHovered,
secondaryForeground: secondaryForeground ?? this.secondaryForeground,
muted: muted ?? this.muted,
mutedForeground: mutedForeground ?? this.mutedForeground,
destructive: destructive ?? this.destructive,
border: border ?? this.border,
divider: divider ?? this.divider,
surfaceSunken: surfaceSunken ?? this.surfaceSunken,
controlFill: controlFill ?? this.controlFill,
controlFillHovered: controlFillHovered ?? this.controlFillHovered,
controlFillFocused: controlFillFocused ?? this.controlFillFocused,
controlBorder: controlBorder ?? this.controlBorder,
switchTrackInactive: switchTrackInactive ?? this.switchTrackInactive,
ring: ring ?? this.ring,
chart1: chart1 ?? this.chart1,
chart2: chart2 ?? this.chart2,
chart3: chart3 ?? this.chart3,
chart4: chart4 ?? this.chart4,
chart5: chart5 ?? this.chart5,
chrome: chrome ?? this.chrome,
panelBorder: panelBorder ?? this.panelBorder,
panelBorderHighlighted:
panelBorderHighlighted ?? this.panelBorderHighlighted,
rowHovered: rowHovered ?? this.rowHovered,
rowText: rowText ?? this.rowText,
popoverItemHovered: popoverItemHovered ?? this.popoverItemHovered,
propertiesSectionBorder:
propertiesSectionBorder ?? this.propertiesSectionBorder,
);
}
// Accent override - swaps primary/ring to the given accent colour, and picks a
// readable foreground for it. Mirrors what shadcns recolor() did. Pass null
// (the "none" accent) to leave the scheme untouched.
ColourScheme withAccent(Color? accent) {
if (accent == null) return this;
return copyWith(
primary: accent,
primaryHovered: accent,
primaryForeground: _contrastColor(accent),
ring: accent,
);
}
static ColourScheme lerp(ColourScheme a, ColourScheme b, double t) {
if (t <= 0) return a;
if (t >= 1) return b;
Color c(Color x, Color y) => Color.lerp(x, y, t)!;
return ColourScheme(
brightness: t < 0.5 ? a.brightness : b.brightness,
background: c(a.background, b.background),
foreground: c(a.foreground, b.foreground),
card: c(a.card, b.card),
popover: c(a.popover, b.popover),
popoverBorder: c(a.popoverBorder, b.popoverBorder),
tooltipBackground: c(a.tooltipBackground, b.tooltipBackground),
tooltipBorder: c(a.tooltipBorder, b.tooltipBorder),
primary: c(a.primary, b.primary),
primaryHovered: c(a.primaryHovered, b.primaryHovered),
primaryForeground: c(a.primaryForeground, b.primaryForeground),
secondary: c(a.secondary, b.secondary),
secondaryHovered: c(a.secondaryHovered, b.secondaryHovered),
secondaryForeground: c(a.secondaryForeground, b.secondaryForeground),
muted: c(a.muted, b.muted),
mutedForeground: c(a.mutedForeground, b.mutedForeground),
destructive: c(a.destructive, b.destructive),
border: c(a.border, b.border),
divider: c(a.divider, b.divider),
surfaceSunken: c(a.surfaceSunken, b.surfaceSunken),
controlFill: c(a.controlFill, b.controlFill),
controlFillHovered: c(a.controlFillHovered, b.controlFillHovered),
controlFillFocused: c(a.controlFillFocused, b.controlFillFocused),
controlBorder: c(a.controlBorder, b.controlBorder),
switchTrackInactive: c(a.switchTrackInactive, b.switchTrackInactive),
ring: c(a.ring, b.ring),
chart1: c(a.chart1, b.chart1),
chart2: c(a.chart2, b.chart2),
chart3: c(a.chart3, b.chart3),
chart4: c(a.chart4, b.chart4),
chart5: c(a.chart5, b.chart5),
chrome: c(a.chrome, b.chrome),
panelBorder: c(a.panelBorder, b.panelBorder),
panelBorderHighlighted: c(
a.panelBorderHighlighted,
b.panelBorderHighlighted,
),
rowHovered: c(a.rowHovered, b.rowHovered),
rowText: c(a.rowText, b.rowText),
popoverItemHovered: c(a.popoverItemHovered, b.popoverItemHovered),
propertiesSectionBorder: c(
a.propertiesSectionBorder,
b.propertiesSectionBorder,
),
);
}
}
/// [base]'s lightness in CIE L*, 0 (black) to 100 (white).
double lstarOf(Color base) => _lstar(base);
/// [base] moved [points] in CIE L*, keeping its hue and saturation.
///
/// Reflects rather than clamps when theres no room that way: a colour that
/// cant get [points] lighter gets [points] darker instead, keeping the size of
/// the step and losing only its direction. A clamp doesnt shorten a step, it
/// deletes it - two slots asking for +6 and +11 against a near-white ground
/// both land on white and a distinction that exists in every other scheme is
/// gone.
///
/// This is the same step [ColourScheme.derive] is built out of, exposed so an
/// app deriving extra slots of its own (Arcs & Angles' canvas colours) walks
/// the identical ladder rather than reinventing a near-miss of it.
Color shiftLstar(Color base, double points) {
final l = _lstar(base);
final wanted = l + points;
return _atLstar(base, (wanted > 100 || wanted < 0) ? l - points : wanted);
}
// chrome is the deepest recessed slot the shared scheme has, so its want sets
// the squeeze factor for every other one.
const double _deepestSink = 12.6;
// ── CIE L* ───────────────────────────────────────────────────────────────
// The offsets above are all in L*, which is perceptually uniform - five
// points looks like the same step whether youre near black or near white.
// HSL lightness, which this used to use, very much does not.
double _channel(double v) =>
v <= 0.04045 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
double _lstar(Color c) {
final y =
0.2126 * _channel(c.r) + 0.7152 * _channel(c.g) + 0.0722 * _channel(c.b);
return y > 0.008856 ? 116 * math.pow(y, 1 / 3).toDouble() - 16 : 903.3 * y;
}
// [base]'s hue and saturation at the given L*.
//
// Theres no closed form for this that keeps HSL saturation fixed, so it
// bisects on HSL lightness instead - which is fine, its monotonic in
// luminance and this runs once per scheme at startup, not per frame.
Color _atLstar(Color base, double target) {
final hsl = HSLColor.fromColor(base);
final want = target.clamp(0.0, 100.0);
var lo = 0.0;
var hi = 1.0;
for (var i = 0; i < 18; i++) {
final mid = (lo + hi) / 2;
if (_lstar(hsl.withLightness(mid).toColor()) < want) {
lo = mid;
} else {
hi = mid;
}
}
return hsl.withLightness((lo + hi) / 2).toColor();
}
// flips lightness to get a readable foreground on a given colour. same idea as
// shadcns getContrastColor (full luminance contrast).
Color _contrastColor(Color on) {
final hsl = HSLColor.fromColor(on);
final l = hsl.lightness;
final target = l >= 0.5 ? 0.0 : 1.0;
// nudge toward the target rather than pure black/white so it doesnt look harsh
final mixed = lerpDouble(l, target, 1.0)!;
return hsl.withLightness(mixed.clamp(0.0, 1.0)).toColor();
}
// a focus ring wants to read punchier than a resting "primary" swatch -
// lighter and more saturated, so it pops against whatevers behind it instead
// of just matching a button colour.
Color _brightenForSelectionRing(Color colour) {
final hsl = HSLColor.fromColor(colour);
return hsl
.withSaturation((hsl.saturation + 0.16).clamp(0.0, 1.0))
.withLightness((hsl.lightness + 0.13).clamp(0.0, 0.78))
.toColor();
}
+90
View File
@@ -0,0 +1,90 @@
import "package:flutter/widgets.dart";
import "package:google_fonts/google_fonts.dart";
import "package:garage_ui/theme/theme_data.dart";
export "package:garage_ui/theme/theme_data.dart";
export "package:garage_ui/theme/colour_scheme.dart";
export "package:garage_ui/theme/typography.dart";
// The apps theme. Hands the theme data down via a plain inherited scope (no
// animated wrapper, no per frame ColorScheme.lerp flattening the whole thing -
// this is the bit that used to feel second class), AND establishes a sane
// DefaultTextStyle + IconTheme so bare Text/Icon dont fall back to flutter's
// yellow-underline "unstyled" debug look (MaterialApp only styles text inside a
// Material, and our panels arent Material).
class GarageTheme extends StatelessWidget {
const GarageTheme({super.key, required this.data, required this.child});
final ThemeData data;
final Widget child;
static ThemeData of(BuildContext context) {
final t = maybeOf(context);
assert(
t != null,
"No GarageTheme found in context. Wrap the app in an GarageTheme.",
);
return t!;
}
static ThemeData? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_GarageThemeScope>()
?.data;
}
@override
Widget build(BuildContext context) {
final cs = data.colorScheme;
return _GarageThemeScope(
data: data,
child: DefaultTextStyle(
// the one app-wide UI font, declared right here at the top of the tree.
// everything below that doesnt override fontFamily inherits Geist.
// (canvas-painted text - station labels, watermark - sits outside the
// widget tree so it isnt touched by this.)
style: GoogleFonts.geist(
color: cs.foreground,
// off the density, not a literal times scaling. this was the third
// independent source of text size in the package - controls read
// density.fontSize, the .xSmall()/.small()/.large() ladder now reads
// it too, and this floated free at 11 * scaling. Retune fontSize and
// page copy no longer stays behind while the controls move.
//
// fontSize, NOT textXs. The ladder above the control font is a set
// of MULTIPLIERS (xs 1.1x, sm 1.4x, lg 1.8x), so what textXs means
// depends on the tier: at A&A's 10 its 11, a hair over the control
// font and harmless, and at the product tier's 16 its 18 - body copy
// rendering BIGGER than the text inside the buttons and fields next
// to it. Thats backwards, and its what made the product surfaces
// read as oversized even with their control geometry correct.
//
// Body copy is the control font. A sentence and the text in the box
// under it are the same size, at every tier, by construction.
fontSize: data.density.fontSize,
// the important bit - kills the yellow underline.
decoration: TextDecoration.none,
fontWeight: FontWeight.w300,
),
child: IconTheme(
data: IconThemeData(
color: cs.foreground,
size: data.iconTheme.medium.size,
),
child: child,
),
),
);
}
}
class _GarageThemeScope extends InheritedWidget {
const _GarageThemeScope({required this.data, required super.child});
final ThemeData data;
@override
bool updateShouldNotify(_GarageThemeScope oldWidget) =>
oldWidget.data != data;
}
+88
View File
@@ -0,0 +1,88 @@
import "dart:ui" show Color;
import "package:flutter/widgets.dart" show Brightness;
import "package:garage_ui/theme/colour_scheme.dart";
/// Ready-made schemes, so a new app can look like a Garage app on line one.
///
/// Standing one up used to mean authoring 54 colours by hand before writing
/// any app code. These are the neutral pair, built through
/// [ColourScheme.derive] from four colours each — which is also the worked
/// example of how to make your own.
///
/// ```dart
/// GarageApp.router(
/// theme: ThemeData(colorScheme: GarageSchemes.dark),
/// ...
/// )
/// ```
///
/// Want your own hue? Change [ColourScheme.derive]'s `primary` and leave the
/// rest. Want one slot exact? Every derived slot has an override.
abstract final class GarageSchemes {
/// The neutral dark scheme. Same core Arcs & Angles' zinc is built on.
static final ColourScheme dark = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff303030),
foreground: const Color(0xffe6e6e6),
primary: const Color(0xff4772b3),
);
/// The neutral light scheme — the same blue on a faintly cool near-white.
static final ColourScheme light = ColourScheme.derive(
brightness: Brightness.light,
background: const Color(0xfff2f2f4),
foreground: const Color(0xff1a1a1c),
primary: const Color(0xff3a63a1),
);
/// Carbon. True black, no chroma at all - built for oled panels, and the
/// scheme the Garage apps actually run.
///
/// It lives here rather than in each app because it didnt: the hub and Arcs
/// & Angles each kept their own copy and they drifted apart in twenty five
/// of fifty two slots before anyone noticed. One definition cant.
///
/// Carbon has 3.6 L* of room under its ground, against zinc's 19.9, so every
/// recessed rung squeezes into what there is - which is why chrome lands on
/// the floor without being told to. The pins are where carbon wants
/// something other than what the ladder gives it.
static final ColourScheme carbon = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff0d0d0d),
foreground: const Color(0xfff2f2f2),
// no accent hue anywhere - primary is just a near white fill
primary: const Color(0xffe8e8e8),
primaryForeground: const Color(0xff000000),
ring: const Color(0xffffffff),
destructive: const Color(0xffc43333),
// the ladder puts these a shade off the floor. carbon wants the floor -
// the gutter between panels is the whole look.
popover: const Color(0xff000000),
controlFillFocused: const Color(0xff000000),
// above the ground rather than below it, which is what carbon has always
// done with muted. theres barely any "below" left to use.
muted: const Color(0xff0f0f0f),
controlFill: const Color(0xff080808),
tooltipBackground: const Color(0xff080808),
// NOT the ladder's +11.8 over the fill. Carbon cant recess a field far
// enough to read as recessed, so the stroke does all the separating and
// has to be the strongest thing on the control, not the weakest.
controlBorder: const Color(0xff2a2a2a),
chart1: const Color(0xfff45b69),
chart2: const Color(0xff86d957),
chart3: const Color(0xff62a7ff),
chart4: const Color(0xffffc857),
chart5: const Color(0xffff8c42),
);
/// Whichever of the pair matches [brightness].
static ColourScheme of(Brightness brightness) =>
brightness == Brightness.dark ? dark : light;
}
+169
View File
@@ -0,0 +1,169 @@
import "dart:ui" show Color, ImageFilter;
import "package:flutter/painting.dart" show HSLColor;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// small pile of helpers the GarageUI widgets used to pull off the shadcn barrel.
// theyre generic, nothing shadcn specific, so we just keep our own copies.
// shadcns Color helpers that the widgets lean on.
extension ColorExtension on Color {
// scale the alpha channel by [factor] (0..1). shadcns scaleAlpha.
Color scaleAlpha(double factor) {
return withValues(alpha: (a * factor).clamp(0.0, 1.0));
}
// a readable foreground for this colour - flips lightness. shadcns getContrastColor.
Color getContrastColor([double luminanceContrast = 1]) {
final hsl = HSLColor.fromColor(this);
final l = hsl.lightness;
final target = l >= 0.5
? l - (l * luminanceContrast)
: l + ((1 - l) * luminanceContrast);
return hsl.withLightness(target.clamp(0.0, 1.0)).toColor();
}
}
// default anim duration shadcn used all over (hover/colour transitions etc).
const Duration kDefaultDuration = Duration(milliseconds: 150);
// widget value wins, then theme value, then the hard default. shadcns styleValue.
T styleValue<T>({T? widgetValue, T? themeValue, required T defaultValue}) {
return widgetValue ?? themeValue ?? defaultValue;
}
// we dropped shadcns density-insets system, so this is now just a passthrough -
// the GarageUI widgets only ever hand it plain EdgeInsets anyway.
EdgeInsetsGeometry resolveEdgeInsets(
EdgeInsetsGeometry padding,
double basePadding,
) {
return padding;
}
// shrinks a border radius by the border width so an inner surface tucks neatly
// inside its border. shadcns subtractByBorder.
BorderRadius subtractByBorder(BorderRadius radius, double borderWidth) {
Radius sub(Radius r) => Radius.elliptical(
(r.x - borderWidth).clamp(0.0, double.infinity),
(r.y - borderWidth).clamp(0.0, double.infinity),
);
return BorderRadius.only(
topLeft: sub(radius.topLeft),
topRight: sub(radius.topRight),
bottomLeft: sub(radius.bottomLeft),
bottomRight: sub(radius.bottomRight),
);
}
// used to be a density-aware Padding in shadcn. our padding is already resolved
// by the time it gets here, so this is just a Padding.
class DensityContainerPadding extends StatelessWidget {
const DensityContainerPadding({
super.key,
required this.padding,
required this.child,
});
final EdgeInsetsGeometry padding;
final Widget child;
@override
Widget build(BuildContext context) => Padding(padding: padding, child: child);
}
// shadcn used this to tell whether a surface was being rendered inside a sheet
// so it could drop its own rounding/border. the app doesnt use sheets, so its
// always false.
class SheetOverlayHandler {
const SheetOverlayHandler._();
static bool isSheetOverlay(BuildContext context) => false;
}
// draws a focus ring around a child when [focused]. shadcns FocusOutline, cut
// down to what the text field needs.
class FocusOutline extends StatelessWidget {
const FocusOutline({
super.key,
required this.child,
required this.focused,
this.borderRadius,
this.color,
this.width = 1.0,
});
final Widget child;
final bool focused;
final BorderRadiusGeometry? borderRadius;
final Color? color;
final double width;
@override
Widget build(BuildContext context) {
if (!focused) return child;
final ring = color ?? GarageTheme.of(context).colorScheme.ring;
return Container(
decoration: BoxDecoration(
borderRadius: borderRadius,
// draw the ring OUTSIDE the box so it doesnt inset the child and
// change the field size when focus comes and goes.
border: Border.all(
color: ring,
width: width,
strokeAlign: BorderSide.strokeAlignOutside,
),
),
child: child,
);
}
}
// blurs whatever is behind the child (glassmorphism on popovers/cards). ported
// straight from shadcns outlined_container.dart.
class SurfaceBlur extends StatefulWidget {
const SurfaceBlur({
super.key,
required this.child,
this.surfaceBlur,
this.borderRadius,
});
final Widget child;
final double? surfaceBlur;
final BorderRadiusGeometry? borderRadius;
@override
State<SurfaceBlur> createState() => _SurfaceBlurState();
}
class _SurfaceBlurState extends State<SurfaceBlur> {
final GlobalKey _mainContainerKey = GlobalKey();
@override
Widget build(BuildContext context) {
if (widget.surfaceBlur == null || widget.surfaceBlur! <= 0) {
return KeyedSubtree(key: _mainContainerKey, child: widget.child);
}
return Stack(
fit: StackFit.passthrough,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: widget.borderRadius ?? BorderRadius.zero,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.surfaceBlur!,
sigmaY: widget.surfaceBlur!,
),
// needs a child or it wont actually blur anything
child: const SizedBox(),
),
),
),
KeyedSubtree(key: _mainContainerKey, child: widget.child),
],
);
}
}
+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,
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import "package:flutter/widgets.dart";
import "package:google_fonts/google_fonts.dart";
import "package:garage_ui/theme/garage_theme.dart";
// The apps type scale. shadcn exposed a big Typography object; the GarageUI widgets
// only reach for the small set below. `small` is a size style, `medium`/`normal`
// are weight styles, and `mono` swaps the font family while inheriting the
// ambient size/weight unless a caller overrides them.
class Typography {
const Typography({
this.normal = const TextStyle(fontWeight: FontWeight.w400),
this.medium = const TextStyle(fontWeight: FontWeight.w500),
this.semiBold = const TextStyle(fontWeight: FontWeight.w600),
// `small` is the shared control font used by buttons, fields and selects.
// Prefer [Typography.forDensity] over setting this by hand - the size
// and line height belong to the density, and the whole control geometry
// chain hangs off them.
this.small = const TextStyle(
fontSize: 10,
height: 1.1,
fontWeight: FontWeight.w400,
),
});
/// Builds the control type from the density, so `small` can never drift from
/// the line box the control heights are derived from.
factory Typography.forDensity(Density density) => Typography(
small: TextStyle(
fontSize: density.fontSize,
height: density.lineHeight,
fontWeight: FontWeight.w400,
),
);
final TextStyle normal;
final TextStyle medium;
/// Emphasis above [medium] - section labels, a dialog's title. Was a
/// FontWeight.w600 literal in menu.dart, properties.dart and toast.dart.
final TextStyle semiBold;
final TextStyle small;
TextStyle sansStyle(TextStyle style) =>
GoogleFonts.geist(textStyle: style, fontWeight: style.fontWeight);
TextStyle get mono => GoogleFonts.geistMono();
TextStyle monoStyle(TextStyle style) =>
GoogleFonts.geistMono(textStyle: style, fontWeight: style.fontWeight);
}
// shadcn hung these little text helpers off every widget (usually a Text). they
// merge a style change over whatever DefaultTextStyle is in scope. sizes are
// multiplied by the theme scaling so they track the rest of the ui.
extension TextStyleExtension on Widget {
// sizes come off the density's text ladder, NOT a literal times scaling -
// see Density.textXs for why. scaling is left out on purpose: Density isn't
// scaled, and these have to stay in step with the control font.
Widget xSmall() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textXs),
);
Widget small() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textSm),
);
Widget large() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textLg),
);
Widget medium() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w500),
);
Widget semiBold() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w600),
);
Widget bold() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w700),
);
Widget muted() => _StyledText(
child: this,
style: (t) => TextStyle(color: t.colorScheme.mutedForeground),
);
}
typedef _StyleFromTheme = TextStyle Function(ThemeData theme);
class _StyledText extends StatelessWidget {
const _StyledText({required this.child, required this.style});
final Widget child;
final _StyleFromTheme style;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return DefaultTextStyle.merge(style: style(theme), child: child);
}
}
+471
View File
@@ -0,0 +1,471 @@
// App-level toast entry point, built on top of overlay.dart's generic
// showToast/ToastLocation/_ToastHost plumbing rather than replacing it.
//
// Two looks live side by side:
// - compact/touch layouts keep the plain SurfaceCard+Basic toast from
// before this file existed - bottom right of the WINDOW, no bar, no
// close button, goes through showToast()'s own root-Overlay host.
// - wide desktop layouts get the redesigned card below: severity icon,
// a countdown bar along the bottom, an explicit close button - anchored
// to the top right of the CANVAS specifically (not the window) by
// [CanvasToastLayer], which sits inside the canvas's own Stack instead
// of the root Overlay. that's a deliberate second, independent path
// rather than reusing _ToastCardState's timer/hover state - there's no
// clean way to reach into that private state from outside overlay.dart,
// and duplicating "pause on hover, count down, close" is cheap next to
// trying to thread a canvas rect through the root Overlay's coordinate
// space instead.
import "dart:async";
import "package:flutter/semantics.dart";
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/button.dart";
import "package:garage_ui/extensions.dart";
import "package:garage_ui/overlay.dart";
import "package:garage_ui/surface.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
enum ToastSeverity { info, success, error }
class ToastRequest {
final Object id;
final String title;
final String? subtitle;
final ToastSeverity severity;
// null = stays up until the user closes it - no timer, no drain bar.
final Duration? showDuration;
ToastRequest({
required this.id,
required this.title,
this.subtitle,
required this.severity,
required this.showDuration,
});
}
// plain singleton rather than something threaded through providers - toasts
// are fire and forget from anywhere (editor actions, dialogs, panels) and
// theres only ever one CanvasToastLayer listening at a time.
class _DesktopToastQueue extends ChangeNotifier {
_DesktopToastQueue._();
static final _DesktopToastQueue instance = _DesktopToastQueue._();
final List<ToastRequest> requests = [];
void add(ToastRequest r) {
requests.add(r);
notifyListeners();
}
void remove(Object id) {
final before = requests.length;
requests.removeWhere((r) => r.id == id);
if (requests.length != before) notifyListeners();
}
}
/// Shows an app toast. [isMobile] lets a caller that already knows better
/// (an editor variant flag, say) settle it explicitly; left null it falls
/// back to a width check so a plain call still does the sensible thing.
/// [showDuration] null means the toast stays up until closed by hand - the
/// desktop card just skips the timer/bar entirely; the mobile toast's own
/// showToast() primitive has no concept of "forever", so it gets a long but
/// finite stand-in instead.
void showAppToast({
required BuildContext context,
required String title,
String? subtitle,
ToastSeverity severity = ToastSeverity.info,
Duration? showDuration = const Duration(seconds: 5),
bool? isMobile,
}) {
final mobile = isMobile ?? MediaQuery.sizeOf(context).width < 900;
if (mobile) {
showToast(
context: context,
location: ToastLocation.bottomRight,
showDuration: showDuration ?? const Duration(days: 1),
builder: (context, overlay) => SurfaceCard(
child: Basic(
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle),
),
),
);
return;
}
_DesktopToastQueue.instance.add(
ToastRequest(
id: UniqueKey(),
title: title,
subtitle: subtitle,
severity: severity,
showDuration: showDuration,
),
);
}
/// Sits inside the canvas's own Stack (not the root Overlay) so it anchors
/// to the canvas's top-right corner rather than the window's. Self-hides
/// when theres nothing queued, so its basically free to leave mounted.
class CanvasToastLayer extends StatefulWidget {
const CanvasToastLayer({super.key});
@override
State<CanvasToastLayer> createState() => _CanvasToastLayerState();
}
class _CanvasToastLayerState extends State<CanvasToastLayer> {
@override
void initState() {
super.initState();
_DesktopToastQueue.instance.addListener(_onChange);
}
@override
void dispose() {
_DesktopToastQueue.instance.removeListener(_onChange);
super.dispose();
}
void _onChange() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final requests = _DesktopToastQueue.instance.requests;
if (requests.isEmpty) return const SizedBox.shrink();
final theme = GarageTheme.of(context);
// containerPadding is a "form/dialog" spacing token - too generous for
// sitting right at the canvas edge. containerGap reads much closer to
// how other edge-pinned canvas chrome (HUD, controls overlay) sits.
final pad = theme.density.containerGap;
return Positioned(
top: pad,
right: pad,
width: 300 * theme.scaling,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topRight,
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final r in requests)
Padding(
key: ValueKey(r.id),
padding: EdgeInsets.only(bottom: theme.density.containerGap),
child: _ToastCard(
request: r,
onDismiss: () => _DesktopToastQueue.instance.remove(r.id),
),
),
],
),
),
);
}
}
/// The desktop toast card - severity icon, title/subtitle, close button, and
/// a countdown bar along the bottom that drains as [ToastRequest.showDuration]
/// elapses. Hovering pauses the countdown (bar freezes); leaving resumes it
/// from wherever it left off, not a full reset.
class _ToastCard extends StatefulWidget {
const _ToastCard({required this.request, required this.onDismiss});
final ToastRequest request;
final VoidCallback onDismiss;
@override
State<_ToastCard> createState() => _ToastCardState();
}
class _ToastCardState extends State<_ToastCard> with TickerProviderStateMixin {
late final AnimationController _entry;
// null when the request has no showDuration - permanent, no countdown, no
// bar, closes only via the X.
AnimationController? _progress;
bool _closing = false;
@override
void initState() {
super.initState();
// A toast never takes focus, so nothing walks a screen reader onto it -
// it has to be pushed. The liveRegion flag below covers the case where
// the card is already mounted and its text changes; this covers the far
// more common one, which is the card appearing at all.
final r = widget.request;
SemanticsService.announce(
r.subtitle == null ? r.title : "${r.title}. ${r.subtitle}",
// toasts sit top-right in LTR; the direction only steers where the
// announcement is attributed, not what gets read.
TextDirection.ltr,
assertiveness: r.severity == ToastSeverity.error
? Assertiveness.assertive
: Assertiveness.polite,
);
_entry = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
)..forward();
final duration = widget.request.showDuration;
if (duration != null) {
final progress = AnimationController(vsync: this, duration: duration);
progress.addStatusListener(_onProgressStatus);
progress.forward();
_progress = progress;
}
}
void _onProgressStatus(AnimationStatus status) {
if (status == AnimationStatus.completed) _startClose();
}
void _startClose() {
if (_closing) return;
_closing = true;
_progress?.stop();
unawaited(
_entry.reverse().whenComplete(() {
if (mounted) widget.onDismiss();
}),
);
}
void _pause() {
if (!_closing) _progress?.stop();
}
void _resume() {
if (!_closing && _progress?.isAnimating == false) _progress!.forward();
}
@override
void dispose() {
_entry.dispose();
_progress?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
final IconData icon;
final Color accent;
switch (widget.request.severity) {
case ToastSeverity.success:
icon = LucideIcons.circle_check;
accent = scheme.chart2;
break;
case ToastSeverity.error:
icon = LucideIcons.circle_alert;
accent = scheme.destructive;
break;
case ToastSeverity.info:
icon = LucideIcons.info;
accent = scheme.primary;
break;
}
return AnimatedBuilder(
animation: _entry,
builder: (context, child) {
final t = Curves.easeOutCubic.transform(_entry.value.clamp(0.0, 1.0));
return Opacity(
opacity: t,
child: Transform.translate(
offset: Offset((1 - t) * 24, 0),
child: child,
),
);
},
child: Semantics(
liveRegion: true,
container: true,
child: MouseRegion(
onEnter: (_) => _pause(),
onExit: (_) => _resume(),
child: SurfaceCard(
padding: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
// same fill/border pair AlertDialog uses (ModalContainer) rather
// than the plain card colours - a toast is a floating overlay like
// a dialog, not an in-canvas panel, so it reads better matching that.
filled: true,
fillColor: scheme.popover,
borderColor: scheme.popoverBorder,
borderWidth: 1 * theme.scaling,
// xxl matches the dialog's colours but was too pillowy for a
// toast this short - md keeps the family resemblance without it.
borderRadius: theme.borderRadiusMd,
// Column, not Stack+Positioned - a Positioned bar doesn't grow the
// Stack's own size, it just overlays wherever "bottom: 0" lands
// WITHIN whatever height the text already claimed. for a long,
// wrapped subtitle that's directly behind the last line of text -
// invisible. a real Column child always gets its own reserved
// strip below the content, however tall that content gets.
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Mirrors AlertDialog's own Row/Column, not Basic's generic
// list-tile styling - same icon treatment (iconLarge), and
// title/subtitle set to the SAME text style, distinguished by
// colour only (full foreground vs muted) rather than size or
// weight, same as the dialog's title/content pairing.
Padding(
padding: EdgeInsets.all(theme.density.containerGap),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// same border/fill/radius formula as ButtonVariance.outline
// (_buttonOutlineDecoration) - reads as a matching control
// rather than a bespoke badge. icon stays accent-coloured
// (not the outline button's usual mutedForeground) since
// that colour is the whole point of a severity icon.
// no * scaling here either - every explicit Icon(size:)
// literal elsewhere in the app (explorer rows included)
// is a flat number, only the iconSmall/Medium/Large
// extensions multiply by scaling.
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: const Color(0x00000000),
border: Border.all(
color: scheme.controlBorder,
width: theme.density.controlBorderWidth,
),
borderRadius: theme.borderRadiusMd,
),
child: Center(
child: Icon(
icon,
size: 18,
color: const Color(0xFFFFFFFF),
),
),
),
SizedBox(width: theme.density.containerGap),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// no fontSize at all, same as the explorer panel's
// _RootRow label - both inherit GarageTheme's own
// root DefaultTextStyle (11 * scaling) instead of a
// second hand-typed number that can drift from it.
// fontWeight w400 matches that same row's override;
// colour is the only thing telling title/subtitle
// apart.
Text(
widget.request.title,
style: TextStyle(
fontWeight: FontWeight.w400,
color: scheme.foreground,
),
),
if (widget.request.subtitle
case final subtitle?) ...[
SizedBox(
height: theme.density.containerGap * 0.375,
),
Text(
subtitle,
style: TextStyle(
fontWeight: FontWeight.w400,
color: scheme.mutedForeground,
),
),
],
],
),
),
SizedBox(width: theme.density.containerGap),
IconButton.outline(
icon: const Icon(LucideIcons.x).iconSmall,
onPressed: _startClose,
),
],
),
),
if (_progress case final progress?)
AnimatedBuilder(
animation: progress,
builder: (context, _) => _ToastDrainBar(
remainingFraction: 1 - progress.value,
color: accent,
trackColor: scheme.popoverBorder,
thickness: 3,
),
),
],
),
),
),
),
);
}
}
class _ToastDrainBar extends StatelessWidget {
const _ToastDrainBar({
required this.remainingFraction,
required this.color,
required this.trackColor,
required this.thickness,
});
final double remainingFraction;
final Color color;
final Color trackColor;
final double thickness;
@override
Widget build(BuildContext context) {
// Row/Expanded rather than Stack + Align + FractionallySizedBox. the
// latter renders nothing here: an unparented ColoredBox has no intrinsic
// size, so once FractionallySizedBox hands it a loose constraint it
// collapses to zero width and the whole bar disappears. Expanded's flex
// is resolved against the Row's own width, so each half always gets a
// real tight constraint no matter how the bar is nested.
final fill = (remainingFraction.clamp(0.0, 1.0) * 1000).round();
final rest = 1000 - fill;
return SizedBox(
height: thickness,
child: Row(
// stretch is load-bearing, NOT cosmetic. a ColoredBox with no child
// collapses to the smallest size its constraints allow, and Row's
// default (center) hands children a LOOSE height - so both halves
// sized to height 0 and the bar vanished while still "building"
// perfectly happily. stretch makes that height tight.
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (fill > 0)
Expanded(
flex: fill,
child: ColoredBox(color: color),
),
if (rest > 0)
Expanded(
flex: rest,
child: ColoredBox(color: trackColor.scaleAlpha(0.4)),
),
],
),
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import Cocoa
import FlutterMacOS
// Hides the OS cursor and keeps it pinned in place for the duration of a
// drag, streaming raw pointer deltas back to Dart ourselves.
//
// Why not just let Flutters own pointer events drive this: Flutter computes
// PointerEvent.delta from the ABSOLUTE cursor position of consecutive
// events, not from raw hardware deltaX/deltaY. An earlier version tried to
// keep the cursor pinned by warping it back to an anchor point after every
// move while staying associated in between - that still let the OS visibly
// move the cursor between warps, and the warps themselves raced against
// real movement over the async Dart<->native round trip.
//
// The actual fix: CGAssociateMouseAndMouseCursorPosition(false) for the
// WHOLE gesture, not toggled per-event. WindowServer then simply stops
// moving the on-screen cursor at all in response to hardware input - no
// per-frame warping needed. mouseDragged events keep firing completely
// normally while disassociated, with correct deltaX/deltaY reflecting real
// HID movement (its only locationInWindow that freezes) - which is exactly
// why we read deltaX/deltaY here instead of position. Re-associating on
// unlock resumes normal 1:1 tracking from wherever the cursor was frozen,
// so it naturally reappears back where the drag started.
final class CursorLock {
static let shared = CursorLock()
private var locked = false
private var monitor: Any?
// strong on purpose - CursorLock.shared is a permanent singleton, theres
// no retain cycle risk, and a weak ref here previously meant the channel
// (whose only other strong reference was a local `let` in awakeFromNib)
// got deallocated the moment that function returned, silently breaking
// delta forwarding.
private var channel: FlutterMethodChannel?
func attach(channel: FlutterMethodChannel) {
self.channel = channel
}
func lock() {
guard !locked else { return }
locked = true
NSCursor.hide()
CGAssociateMouseAndMouseCursorPosition(0)
monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDragged]) { [weak self] event in
guard let self, self.locked else { return event }
self.channel?.invokeMethod("scrubDelta", arguments: ["dx": event.deltaX, "dy": event.deltaY])
// swallowed - Flutters own position for this event is frozen (and
// therefore meaningless) while disassociated, no point forwarding it.
return nil
}
}
func unlock() {
guard locked else { return }
locked = false
if let monitor {
NSEvent.removeMonitor(monitor)
}
monitor = nil
CGAssociateMouseAndMouseCursorPosition(1)
NSCursor.unhide()
}
}
@@ -0,0 +1,58 @@
import Cocoa
import FlutterMacOS
// Entry point Flutter calls into via RegisterGeneratedPlugins - this is what
// makes CursorLock and the eyedropper "just work" for any app that depends
// on garage_ui, without that app having to hand-wire the channels itself in
// its own MainFlutterWindow.swift. Used to be duplicated per-app, which is
// exactly how it silently went stale (renamed channel on one side, not the
// other) and why a brand new app (no Runner wiring at all) got a
// MissingPluginException out of the box.
public class GarageUiPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = GarageUiPlugin()
let cursorLockChannel = FlutterMethodChannel(
name: "garage/cursor_lock",
binaryMessenger: registrar.messenger
)
// CursorLock needs to hang on to the channel itself so it can push
// scrubDelta calls back to dart - see its own header for why a plain
// local var here isnt enough (it needs a home that outlives this fn)
CursorLock.shared.attach(channel: cursorLockChannel)
registrar.addMethodCallDelegate(instance, channel: cursorLockChannel)
let eyedropperChannel = FlutterMethodChannel(
name: "garage/eyedropper",
binaryMessenger: registrar.messenger
)
registrar.addMethodCallDelegate(instance, channel: eyedropperChannel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "lock":
CursorLock.shared.lock()
result(nil)
case "unlock":
CursorLock.shared.unlock()
result(nil)
case "isAvailable":
if #available(macOS 10.15, *) {
result(true)
} else {
result(false)
}
case "pick":
if #available(macOS 10.15, *) {
ScreenColorSampler.shared.show(result: result)
} else {
// dart reads nil as a cancel, which is close enough - it only ever
// asks for a pick after isAvailable said yes anyway
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
}
@@ -0,0 +1,58 @@
import Cocoa
import FlutterMacOS
// Screen wide colour picking via NSColorSampler - the same magnifier loupe
// the system colour panel hands you when you click its eyedropper.
//
// Worth knowing: this runs out of process (WindowServer does the actual
// grabbing), so unlike anything built on CGDisplayCreateImage it needs NO
// screen recording permission and never triggers that prompt. It also draws
// its own zoomed preview, which is why the dart side doesnt bother streaming
// a live preview while this is up - theres nothing to add.
//
// Its 10.15+. Older systems just fall through to sampling the apps own
// window in dart, so the button never disappears on anyone.
@available(macOS 10.15, *)
final class ScreenColorSampler {
static let shared = ScreenColorSampler()
// held for the duration of the session. NSColorSampler is one shot per
// instance and only one can be on screen at a time, so this doubles as the
// "already sampling" flag.
private var sampler: NSColorSampler?
func show(result: @escaping FlutterResult) {
guard sampler == nil else {
result(nil)
return
}
let sampler = NSColorSampler()
self.sampler = sampler
sampler.show { [weak self] color in
self?.sampler = nil
// nil means escape / clicked away, thats a cancel not an error
guard let color = color else {
result(nil)
return
}
// the sample comes back in whatever space the sampled display uses,
// convert or the numbers wont match the hex the user sees anywhere else
guard let srgb = color.usingColorSpace(.sRGB) else {
result(nil)
return
}
func channel(_ value: CGFloat) -> Int {
return Int((min(max(value, 0), 1) * 255).rounded())
}
result([
"r": channel(srgb.redComponent),
"g": channel(srgb.greenComponent),
"b": channel(srgb.blueComponent),
])
}
}
}
+27
View File
@@ -0,0 +1,27 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint garage_ui.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'garage_ui'
s.version = '0.0.1'
s.summary = 'Native macOS glue for garage_ui.'
s.description = <<-DESC
Native macOS support for garage_ui - cursor lock (for text field scrubbing)
and the NSColorSampler-backed eyedropper. Every other platform stays pure
dart, this podspec only exists so macOS apps that depend on garage_ui get
both wired up automatically instead of having to hand-register the channels
themselves in MainFlutterWindow.swift.
DESC
s.homepage = 'http://example.com'
s.license = { :type => 'MIT' }
s.author = { 'Garage' => 'dev@example.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.15'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
s.swift_version = '5.0'
end
+24
View File
@@ -0,0 +1,24 @@
name: garage_ui
description: A compact, desktop-first Flutter UI system for Garage applications.
publish_to: none
version: 0.1.0
environment:
sdk: ^3.10.0-75.1.beta
dependencies:
flutter:
sdk: flutter
flutter_lucide: ^1.11.0
google_fonts: ^8.0.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
plugin:
platforms:
macos:
pluginClass: GarageUiPlugin
+185
View File
@@ -0,0 +1,185 @@
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A centred label sat in an Expanded, so it centred in the space LEFT OVER
// after a leading icon rather than across the button - one icon threw the text
// off centre by half its own width plus the gap.
void main() {
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 300, child: child)),
),
);
Future<double> offset(WidgetTester t, Widget b) async {
await t.pumpWidget(host(b));
return t.getRect(find.text("Label")).center.dx -
t.getRect(find.byType(Button)).center.dx;
}
Button btn({Widget? leading, Widget? trailing, AlignmentGeometry? alignment}) =>
Button(
style: const ButtonStyle.secondary(),
alignment: alignment,
leading: leading,
trailing: trailing,
onPressed: () {},
child: const Text("Label"),
);
const icon = Icon(LucideIcons.mail);
_fieldCentring();
_labelNeverWraps();
testWidgets("centred label is centred with no icons", (t) async {
expect(await offset(t, btn(alignment: Alignment.center)), 0.0);
});
testWidgets("centred label is centred with a leading icon", (t) async {
expect(await offset(t, btn(alignment: Alignment.center, leading: icon)), 0.0);
});
testWidgets("centred label is centred with a trailing icon", (t) async {
expect(await offset(t, btn(alignment: Alignment.center, trailing: icon)), 0.0);
});
testWidgets("centred label is centred with both", (t) async {
expect(
await offset(t,
btn(alignment: Alignment.center, leading: icon, trailing: icon)),
0.0);
});
testWidgets("no alignment means no balancing - the button still shrink-wraps",
(t) async {
// without an alignment there is no centre to miss, and padding the empty
// side out would only make the button wider.
// unconstrained, so the button reports its own intrinsic width
Widget loose(Widget c) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: c),
),
);
await t.pumpWidget(loose(btn(leading: icon)));
final unaligned = t.getSize(find.byType(Button)).width;
await t.pumpWidget(loose(btn(alignment: Alignment.center, leading: icon)));
final aligned = t.getSize(find.byType(Button)).width;
// the balanced one is wider by exactly the mirrored icon plus its gap
expect(aligned, greaterThan(unaligned));
expect(aligned - unaligned,
const Density().iconSize + const Density().controlGap);
});
testWidgets("an icon button with a leading icon isnt balanced out", (t) async {
// there is no label in here to sit off centre - both slots are glyphs - so
// the mirror spacer is 15px of nothing. the snap targets button in the
// blender header measured 52 wide for 38 of content because of it.
Widget loose(Widget c) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: c),
),
);
await t.pumpWidget(loose(IconButton.outline(
density: ControlDensity.compact,
leading: Icon(LucideIcons.ruler_dimension_line).iconSmall,
icon: Icon(LucideIcons.chevron_down).iconSmall,
onPressed: () {},
)));
const d = Density();
final pad = ControlDensity.compact
.resolveIcon(ThemeData(colorScheme: testScheme, density: d));
expect(
t.getSize(find.byType(IconButton)).width,
pad.horizontal + d.iconSize * 2 + d.controlGap,
);
});
}
// The same defect in TextField: centred text sits in an Expanded, so a leading
// feature pushed it off the field's centre by half that feature plus the gap.
void _fieldCentring() {
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 300, child: child)),
),
);
testWidgets("centred field text is centred with a leading feature",
(t) async {
await t.pumpWidget(host(const TextField(
initialValue: "abc",
readOnly: true,
enabled: false,
textAlign: TextAlign.center,
features: [InputFeature.leading(Icon(LucideIcons.mail))],
)));
final field = t.getRect(find.byType(TextField));
final text = t.getRect(find.text("abc"));
expect((text.center.dx - field.center.dx).abs(), lessThan(0.5));
});
testWidgets("balancing uses a spacer, not a second icon", (t) async {
// a copy of the feature would sit in the tree twice, which is how this
// broke a test that looked for exactly one icon inside a field.
await t.pumpWidget(host(const TextField(
initialValue: "abc",
readOnly: true,
enabled: false,
textAlign: TextAlign.center,
features: [InputFeature.leading(Icon(LucideIcons.mail))],
)));
expect(
find.descendant(
of: find.byType(TextField), matching: find.byIcon(LucideIcons.mail)),
findsOneWidget,
);
});
}
// A leading icon takes width off the label's allowance; Expanded forces the
// label to absorb it, and Text used to resolve that by wrapping - "Use" on one
// line and the rest on a second the button's height hid. It reads as a
// truncation but never was one.
void _labelNeverWraps() {
Widget host(Widget child, double cap) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: cap, child: Wrap(children: [child]))),
),
);
for (final cap in [400.0, 130.0, 90.0]) {
testWidgets("label stays on one line at width $cap", (t) async {
await t.pumpWidget(host(
Button(
style: const ButtonStyle.primary(),
leading: const Icon(LucideIcons.mail),
onPressed: () {},
child: const Text("Use passkey"),
),
cap,
));
final rp = t.renderObject(find.text("Use passkey")) as RenderParagraph;
expect(rp.size.height, const Density().lineBox,
reason: "a wrapped label is twice this tall");
});
}
}
+105
View File
@@ -0,0 +1,105 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A segmented control lives in a PropertyRow: bounded WIDTH, unbounded height.
// That combination is the whole point of these tests - `expands: true` drops
// the IntrinsicHeight that gives `stretch` something to stretch to, and the
// buttons collapse to nothing.
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 300, child: child),
),
),
);
Widget _seg(String label) => Button(
style: const ButtonStyle.outline(),
alignment: Alignment.center,
onPressed: () {},
child: Text(label),
);
void main() {
testWidgets("fill splits the main axis evenly", (tester) async {
await tester.pumpWidget(
_host(
ButtonGroup.horizontal(
fill: true,
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
);
expect(tester.takeException(), isNull);
final off = tester.getSize(find.widgetWithText(Button, "Off"));
final optional = tester.getSize(find.widgetWithText(Button, "Optional"));
final required = tester.getSize(find.widgetWithText(Button, "Required"));
// an even share of the 300, not a share of the label lengths
expect(off.width, closeTo(100, 1));
expect(optional.width, closeTo(100, 1));
expect(required.width, closeTo(100, 1));
// and a real height, which is what collapsed before
expect(off.height, greaterThan(0));
});
testWidgets("it survives a narrow field without overflowing", (
tester,
) async {
// a phone-width property row. Three segments that each want ~90px of
// label in 200px is where a fill would blow the right edge off.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 200,
child: ButtonGroup.horizontal(
fill: true,
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
),
),
),
);
expect(tester.takeException(), isNull);
final group = tester.getSize(find.byType(ButtonGroup));
expect(group.width, closeTo(200, 1));
for (final label in ["Off", "Optional", "Required"]) {
final seg = tester.getSize(find.widgetWithText(Button, label));
expect(seg.width, closeTo(200 / 3, 1), reason: label);
expect(seg.height, greaterThan(0), reason: label);
}
});
testWidgets("without fill it still shrink wraps to its labels", (
tester,
) async {
await tester.pumpWidget(
_host(
ButtonGroup.horizontal(
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
);
expect(tester.takeException(), isNull);
final off = tester.getSize(find.widgetWithText(Button, "Off"));
final required = tester.getSize(find.widgetWithText(Button, "Required"));
expect(off.width, lessThan(required.width));
});
}
@@ -0,0 +1,80 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// ChromeBar's height was a hardcoded 30 that didnt move with the tier. At
// normal that left a 27px control 1.5px of margin, and at product the bar came
// out SHORTER than the 33px buttons meant to sit in it - a header its own
// contents didnt fit. It takes the density's token now.
Future<double> _barHeight(WidgetTester tester, Density density) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: const Align(
alignment: Alignment.topCenter,
child: ChromeBar(child: SizedBox.shrink()),
),
),
),
);
await tester.pump();
return tester.getSize(find.byType(ChromeBar)).height;
}
void main() {
for (final density in const [
Density(),
Density.normal(),
Density.product(),
]) {
final label = "${density.controlHeight.toInt()}px controls";
test("a chrome bar clears its own controls ($label)", () {
// one controlGap above and one below - the same air a button gets from
// the button beside it
expect(
density.chromeBarHeight,
density.controlHeight + density.controlGap * 2,
);
// the thing that was actually broken: the bar has to be able to hold
// the control, not merely be near its size
expect(
density.chromeBarHeight,
greaterThan(density.controlHeight),
reason: "a control wouldnt fit inside its own chrome",
);
});
testWidgets("ChromeBar takes that height with none given ($label)", (
tester,
) async {
expect(await _barHeight(tester, density), density.chromeBarHeight);
});
}
testWidgets("an explicit height still wins", (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(
colorScheme: testScheme,
density: const Density.product(),
),
child: const Align(
alignment: Alignment.topCenter,
child: ChromeBar(height: 52, child: SizedBox.shrink()),
),
),
),
);
await tester.pump();
expect(tester.getSize(find.byType(ChromeBar)).height, 52);
});
}
@@ -0,0 +1,43 @@
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// The gap scale is derived from controlGap, and the claim that makes that the
// right base is that two tokens which were authored by hand long before the
// scale existed land exactly on steps of it. If someone retunes controlGap,
// containerGap or containerPadding and that stops being true, the base unit
// has drifted and the scale is lying about where it comes from.
void main() {
for (final density in const [Density(), Density.normal()]) {
final name = density.control.name;
test("$name: gapMd is containerGap, gapXl is containerPadding", () {
expect(density.gapMd, density.containerGap);
expect(density.gapXl, density.containerPadding);
});
test("$name: the scale climbs and stays on whole pixels", () {
final steps = [
density.gapXxs,
density.gapXs,
density.gapSm,
density.gapMd,
density.gapLg,
density.gapXl,
density.gapXxl,
];
for (var i = 1; i < steps.length; i++) {
expect(steps[i], greaterThan(steps[i - 1]),
reason: "step $i should be bigger than the one before it");
}
for (final s in steps) {
expect(s, s.roundToDouble(), reason: "$s is not a whole pixel");
}
});
}
test("compact lands on the sizes the style guide observed in real use", () {
const d = Density();
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
});
}
+131
View File
@@ -0,0 +1,131 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme),
child: Center(child: SizedBox(width: 400, child: child)),
),
);
Widget _row({String? error}) => _host(
SettingsRow(
key: const Key("row"),
label: "Name",
error: error,
field: const TextField(),
),
);
void main() {
testWidgets("the label's aside renders beside it, not instead of it", (
tester,
) async {
await tester.pumpWidget(
_host(
const SettingsRow(
label: "Name",
action: Text("Required"),
field: TextField(),
),
),
);
expect(find.text("Name"), findsOneWidget);
expect(find.text("Required"), findsOneWidget);
// on the label's line, and at the FAR right of its half of the row -
// spaceBetween, the way ProductField does it in the onboarding flow.
final label = tester.getRect(find.text("Name"));
final tag = tester.getRect(find.text("Required"));
final field = tester.getRect(find.byType(TextField));
expect((tag.center.dy - label.center.dy).abs(), lessThan(2));
// tucked against the label rather than pushed to the far end of the
// column - one gap between them, not the whole remaining width.
expect(tag.left - label.right, lessThan(12));
expect(tag.left, greaterThan(label.right - 1));
expect(tag.right, lessThan(field.left));
});
testWidgets("the aside reads as a tag, not as a second label", (
tester,
) async {
await tester.pumpWidget(
_host(
const SettingsRow(
label: "Name",
action: Text("Required"),
field: TextField(),
),
),
);
final tag = tester.widget<Text>(find.text("Required"));
final style = DefaultTextStyle.of(
tester.element(find.text("Required")),
).style.merge(tag.style);
final label = tester.widget<Text>(find.text("Name"));
expect(style.fontSize, const Density().textXxs);
expect(style.color, testScheme.mutedForeground);
expect(style.fontSize, lessThan(label.style!.fontSize!));
});
testWidgets("a property row puts it in the same place", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Price",
scheme: testScheme,
action: const Text("Required"),
child: const TextField(),
),
),
);
final label = tester.getRect(find.text("Price"));
final tag = tester.getRect(find.text("Required"));
final field = tester.getRect(find.byType(TextField));
expect(tag.left - label.right, lessThan(12));
expect(tag.right, lessThan(field.left));
});
testWidgets("the row eases open rather than jumping", (tester) async {
await tester.pumpWidget(_row());
await tester.pumpAndSettle();
final bare = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pumpWidget(_row(error: "nope"));
await tester.pump(); // the frame the change lands on
final atStart = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pump(const Duration(milliseconds: 90));
final midway = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pumpAndSettle();
final settled = tester.getSize(find.byKey(const Key("row"))).height;
expect(settled, greaterThan(bare));
expect(midway, greaterThan(atStart));
expect(midway, lessThan(settled));
});
testWidgets("the outline fades in", (tester) async {
await tester.pumpWidget(_row());
await tester.pumpAndSettle();
await tester.pumpWidget(_row(error: "nope"));
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final fades = tester
.widgetList<FadeTransition>(find.byType(FadeTransition))
.map((f) => f.opacity.value)
.toList();
expect(fades.any((v) => v > 0.0 && v < 1.0), isTrue);
});
}
@@ -0,0 +1,112 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A feature lives INSIDE the field. It never gets to decide how tall the field
// is - that is the density's job and nothing elses.
//
// It used to. The field wrapped its whole content row in textFieldPadding, so
// anything you put in a feature slot got the padding stacked on top of its own
// height. A trailing IconButton is a full control tall (23 at compact), so the
// field came out at 23 + 12 = 35 against a controlHeight of 23 - half again as
// tall as the plain field sat next to it. The packages OWN InputFeature.clear()
// did it too, which is how it survived this long unnoticed.
//
// The existing feature-height probe in the app only ever passed a bare Text, so
// none of this was covered.
Future<double> _height(
WidgetTester tester, {
required Density density,
required List<InputFeature> features,
String text = "",
}) async {
final controller = TextEditingController(text: text);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(
child: SizedBox(
width: 300,
child: TextField(controller: controller, features: features),
),
),
),
),
);
await tester.pump();
return tester.getSize(find.byType(TextField)).height;
}
void main() {
for (final density in const [Density(), Density.normal()]) {
final label = density.control.name;
testWidgets("a trailing button doesnt grow the field ($label)", (
tester,
) async {
final plain = await _height(tester, density: density, features: const []);
final withButton = await _height(
tester,
density: density,
features: [
InputFeature.trailing(
IconButton(
variance: ButtonVariance.ghost,
density: ControlDensity.compact,
icon: const Icon(LucideIcons.eye),
onPressed: () {},
),
),
],
);
expect(plain, density.controlHeight);
expect(
withButton,
plain,
reason:
"a button in a trailing slot pushed the field to $withButton "
"against a plain $plain",
);
});
testWidgets("every built-in feature holds controlHeight ($label)", (
tester,
) async {
final cases = <String, List<InputFeature>>{
"leading icon": const [InputFeature.leading(Icon(LucideIcons.lock))],
"clear": [const InputFeature.clear()],
"spinner": [const InputFeature.spinner()],
"increment": [const InputFeature.incrementButton()],
"scrub": [const InputFeature.scrub()],
};
for (final entry in cases.entries) {
// empty AND filled - the clear button only shows up once theres text.
for (final text in const ["", "12"]) {
final h = await _height(
tester,
density: density,
features: entry.value,
text: text,
);
expect(
tester.takeException(),
isNull,
reason: "${entry.key} threw laying out in the field",
);
expect(
h,
density.controlHeight,
reason: "${entry.key} made the field $h",
);
}
}
});
}
}
+63
View File
@@ -0,0 +1,63 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Density density, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Column(children: [child]),
),
);
void main() {
for (final density in const [Density(), Density.normal()]) {
testWidgets("${density.control.name}: Gap.md takes its extent from the theme",
(tester) async {
await tester.pumpWidget(_host(density, const Gap.md()));
expect(tester.getSize(find.byType(Gap)).height, density.gapMd);
});
}
testWidgets("a literal Gap still wins over the scale", (tester) async {
await tester.pumpWidget(_host(const Density(), const Gap(17)));
expect(tester.getSize(find.byType(Gap)).height, 17.0);
});
_outsideFlex();
testWidgets("the same Gap.md moves when the density does", (tester) async {
await tester.pumpWidget(_host(const Density(), const Gap.md()));
final compact = tester.getSize(find.byType(Gap)).height;
await tester.pumpWidget(_host(const Density.normal(), const Gap.md()));
final normal = tester.getSize(find.byType(Gap)).height;
expect(normal, greaterThan(compact));
});
}
// Regression: a Gap with no Flex parent used to throw at layout time and take
// the enclosing subtree with it. One inside an AnimatedSize destroyed a login
// card in the Garage hub, on a build that analyzed clean.
void _outsideFlex() {
testWidgets("a Gap outside a Flex lays out instead of throwing",
(tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: const Center(child: SizedBox(width: 100, child: Gap.md())),
),
),
);
// it lays out - that is the whole point, it used to take the subtree down
expect(tester.getSize(find.byType(Gap)).height, const Density().gapMd);
// and it says so loudly in debug rather than failing silently
final reported = tester.takeException();
expect(reported, isA<FlutterError>());
expect("$reported", contains("no axis to size along"));
});
}
@@ -0,0 +1,98 @@
import "dart:ui" show Color;
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// CHARACTERISATION BASELINE — do not "fix" these numbers.
//
// This locks the geometry Arcs & Angles renders at today, ahead of the density
// API being reshaped. The API is expected to change; the numbers are not. A
// failure here means the refactor moved something on screen, which is the one
// outcome that isn't allowed.
//
// Captured 28 Aug 2026 from Density() and Density.normal().
void main() {
group("compact", () {
const d = Density();
test("type", () {
expect(d.fontSize, 10.0);
expect(d.lineHeight, 1.1);
expect(d.lineBox, 11.0);
// level with the font, not the line box - an icon sized to the line box
// reads heavier than the label beside it
expect(d.iconSize, 10.0);
});
test("text ladder", () {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
});
test("control geometry", () {
expect(d.controlHeight, 23.0);
expect(d.controlPaddingY, 6.0);
expect(d.buttonPaddingX, 10.0);
expect(d.controlGap, 4.0);
expect(d.controlBorderWidth, 1.0);
expect(d.buttonPadding.horizontal, 20.0);
expect(d.buttonPadding.vertical, 12.0);
expect(d.borderedControlPadding.horizontal, 18.0);
expect(d.borderedControlPadding.vertical, 10.0);
});
test("rows", () {
expect(d.menuRowHeight, 20.0);
expect(d.popupRowHeight, 20.0);
expect(d.popupMaxHeight, 240.0);
expect(d.explorerRowHeight, 22.0);
expect(d.explorerRowPaddingY, 5.5);
expect(d.explorerRowBasePadding, 4.0);
expect(d.explorerRowEndPadding, 10.0);
expect(d.listRowIndent, 6.0);
});
test("containers and gaps", () {
expect(d.containerGap, 8.0);
expect(d.containerPadding, 16.0);
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
});
});
group("normal", () {
const d = Density.normal();
test("type", () {
expect(d.fontSize, 10.0);
expect(d.lineBox, 11.0);
});
test("text ladder", () {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
});
test("control geometry", () {
expect(d.controlHeight, 27.0);
expect(d.controlPaddingY, 8.0);
expect(d.buttonPaddingX, 12.0);
expect(d.controlGap, 5.0);
});
test("rows", () {
expect(d.menuRowHeight, 26.0);
expect(d.popupRowHeight, 26.0);
expect(d.explorerRowHeight, 30.0);
expect(d.explorerRowBasePadding, 8.0);
expect(d.explorerRowEndPadding, 14.0);
expect(d.listRowIndent, 12.0);
});
test("containers and gaps", () {
expect(d.containerGap, 10.0);
expect(d.containerPadding, 20.0);
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 30.0]);
});
});
test("icon tiers at scaling 1.0", () {
final t = IconThemeTokens.forDensity(const Density(), 1.0, const Color(0));
expect(t.small.size, 10.0);
expect(t.medium.size, 20.0);
expect(t.large.size, 24.0);
});
}

Some files were not shown because too many files have changed in this diff Show More