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:
+16
@@ -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/
|
||||
@@ -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,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.
|
||||
@@ -0,0 +1,503 @@
|
||||
# Garage UI style guide
|
||||
|
||||
This is not the widget API reference (read the source for that — every file in
|
||||
`lib/` is short and commented). This is the *other* half: which widget, which
|
||||
variant, which colour token, in which situation. Getting a Garage app to use
|
||||
`Button` and `Panel` is easy. Getting it to actually look like a Garage app —
|
||||
right variant for the right emphasis, right token for the right surface, right
|
||||
gap for the right kind of space — is the part that doesn't fall out of the
|
||||
API. That's what this document is for.
|
||||
|
||||
It's written from **observed usage** in Arcs & Angles (metro_map_maker) and
|
||||
Music Maker, the two real apps built on this package. Every rule below is
|
||||
backed by a real call site, not a guess at what "should" be idiomatic. Where
|
||||
a pattern only has 1-2 examples, that's said explicitly — treat it as a lead,
|
||||
not a law.
|
||||
|
||||
## Where this comes from
|
||||
|
||||
Two influences, and they are not the same kind of influence — conflating them
|
||||
is the usual misreading:
|
||||
|
||||
- **shadcn (via `shadcn_flutter`) — the API surface.** Variant names
|
||||
(`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`), the
|
||||
`ColourScheme` slot vocabulary, the dot-constructor shape. This is why a
|
||||
shadcn snippet usually *compiles*.
|
||||
- **Blender — the rendering.** Flat chrome, bordered panels, dense controls,
|
||||
no elevation, a properties pane with a hard split down it. This is why the
|
||||
same snippet doesn't *look* like shadcn once it runs.
|
||||
|
||||
So: **not a pixel-for-pixel restyle of shadcn.** The names carried over; the
|
||||
geometry, the density and the entire chrome layer did not. `shadcn_flutter`
|
||||
was dropped as a dependency once the port finished — nothing here defers to
|
||||
it, and where the two disagree on how something should look, this package
|
||||
wins. Expect ported code to compile and then need its spacing and emphasis
|
||||
re-picked against the tables below.
|
||||
|
||||
## Mental model
|
||||
|
||||
A Garage app has three layers, outside-in:
|
||||
|
||||
1. **Chrome** — the app's own furniture: headers, footers, the menu bar, the
|
||||
shell that docks everything else. Flat, dark, no elevation. Reads as part
|
||||
of the window, not as content sitting on the window.
|
||||
2. **Panels** — bordered, rounded, elevated-feeling regions that hold actual
|
||||
tool content (explorer trees, property inspectors, docked windows). This
|
||||
is where most of the UI actually lives.
|
||||
3. **Controls** — buttons, fields, selects, menus. Live inside panels or
|
||||
chrome, never bare against the app background.
|
||||
|
||||
Nothing in this system uses Flutter's Material widgets. There is no
|
||||
`ThemeData.dark()`, no `Colors.black`, no `Scaffold`. Every colour comes from
|
||||
`ColourScheme`, every size comes from `Density`, every font comes from
|
||||
`Typography`. If a widget you're building reaches for `package:flutter/material.dart`
|
||||
or a literal `Color(0x...)` outside `theme/*.dart`, that's the tell you've
|
||||
stepped outside the system.
|
||||
|
||||
## Getting the theme
|
||||
|
||||
```dart
|
||||
final theme = GarageTheme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
```
|
||||
|
||||
Everything hangs off `theme`: `theme.colorScheme`, `theme.density`,
|
||||
`theme.typography`, `theme.iconTheme`, `theme.radiusMd` / `.borderRadiusMd`
|
||||
(and `Sm`/`Lg`/`Xl`/`Xxl`), `theme.panelRadius`, `theme.panelGap`.
|
||||
|
||||
One exception: an app's *unaccented* scheme — the one nobody's accent-colour
|
||||
override has touched — is read through the app's settings provider, not
|
||||
`GarageTheme.of(context)`. In Arcs & Angles that's
|
||||
`context.watch<SettingsProvider>().anaScheme`. Reach for this specifically
|
||||
when you need `chrome` on something that must stay neutral even if the user
|
||||
picked a wild accent colour (this is rare — most code never needs to do this,
|
||||
because `ChromeBar`/`Panel`/`GarageShell` already read `chrome`/`panel`
|
||||
internally). See "Colour tokens" below for why `chrome` gets this treatment.
|
||||
|
||||
## Colour tokens
|
||||
|
||||
`ColourScheme` (in `theme/colour_scheme.dart`) is one flat list of named
|
||||
colours — no light/dark split, no derived roles computed at paint time. Every
|
||||
field is authored by hand per scheme (Arcs & Angles ships eleven: zinc,
|
||||
crimson, slate, forest, stone, teal, indigo, amber, carbon, fuchsia, plus each
|
||||
one's light twin). When you add a new UI surface, you're choosing which of
|
||||
these *existing* tokens it belongs to — you're not inventing a new colour.
|
||||
|
||||
### Base semantic slots (shadcn-shaped, still the backbone)
|
||||
|
||||
| Token | What it's for |
|
||||
|---|---|
|
||||
| `background` / `foreground` | The app's base surface and default text colour. |
|
||||
| `card` / `cardForeground` | `Card`/`SurfaceCard` fill and the text colour merged inside them. |
|
||||
| `popover` / `popoverForeground` / `popoverBorder` | Dropdowns, select popups, context menus — anything that floats over content in an `OverlayPortal`. |
|
||||
| `primary` / `primaryHovered` / `primaryForeground` | The accent. Active/engaged control state, the one CTA in a dialog. Swappable per-user via `withAccent()`. |
|
||||
| `secondary` / `secondaryHovered` / `secondaryForeground` | Neutral filled control — a resting toggle, an attached-to-a-field icon button. Not an accent colour, just "has a fill." |
|
||||
| `muted` / `mutedForeground` | De-emphasised text/backgrounds — labels, hints, disabled-adjacent copy. `.muted()` text extension reads `mutedForeground`. |
|
||||
| `destructive` | Delete/discard/record actions. Used sparingly — 3 call sites total in Arcs & Angles. |
|
||||
| `border` | Generic 1px outline — text field default border colour, general dividing lines. |
|
||||
| `divider` | A line *between* things in a layout (menu separators, section dividers) — conceptually different from `border` even though schemes often set them equal. |
|
||||
| `ring` | Keyboard-focus outline. Not the canvas selection ring — see `canvasSelectionRing`. |
|
||||
| `chart1`-`chart5` | Reserved for data visualisation, unused by the UI kit itself. |
|
||||
|
||||
### App-chrome slots (the part shadcn never had)
|
||||
|
||||
| Token | What it's for |
|
||||
|---|---|
|
||||
| `chrome` | Header/footer/menu-bar background. Deliberately nudged off `background` so it reads as *app furniture*, not content. Read via the unaccented scheme (see above) — chrome shouldn't shift when the user picks an accent. `ChromeBar` and `GarageShell` apply this for you; you'd only reach for it by hand building a header from scratch (see `blender_header.dart`, `scene_stats_hud.dart`). |
|
||||
| `panel` / `panelBorder` / `panelBorderHighlighted` | `Panel`'s fill and border — resting vs. "this is the active/hovered one" (see `Panel(active: ...)`). This is the surface docked tool content lives in. |
|
||||
| `input` / `inputBackground` / `inputBackgroundHovered` / `inputBackgroundFocused` / `inputBorder` | Text field fill (three interaction states) and border. |
|
||||
| `explorerRowEven` / `explorerRowOdd` / `explorerRowHovered` / `explorerRowText` | Zebra-striped tree/list rows (explorer panel, layer lists). |
|
||||
| `menuItemText` / `menuItemHovered` | Dropdown/menu row text and hover fill. |
|
||||
| `popoverItemHovered` | Hover fill for popover rows that aren't menu items (kept distinct from `menuItemHovered` while the two are still being evaluated — may merge later). |
|
||||
| `tooltipBackground` / `tooltipBorder` | Tooltips get their own pair rather than reusing `popover*` — they sit on top of *everything* and want more contrast than a panel-level surface. |
|
||||
| `propertiesSectionBackground` / `propertiesSectionBorder` / `propertiesSectionLabel` | The boxed sub-sections inside an object-properties panel. |
|
||||
| `canvasBackdrop` / `canvasPaper` / `canvasGridMinor` / `canvasGridMajor` / `canvasGridMajorDot` / `canvasBoundary` | Authored canvas colours — not derived at paint time, these are picked by hand per scheme like everything else. App-specific (Arcs & Angles' map canvas); a non-canvas app can mostly ignore this group. |
|
||||
| `canvasAlignmentGuide` | Smart-guide lines while dragging/resizing. Deliberately its own slot, not `ring` — `ring` is keyboard focus, a different job. |
|
||||
| `canvasSelectionRing` | Selection outline/handles. Tracks `primary` for most schemes but exists as its own slot so an achromatic scheme (carbon: near-black/near-white primary) can still give selection actual hue. If you add `withAccent()` support anywhere, remember `canvasSelectionRing` needs the same brightened-for-visibility treatment `_brightenForSelectionRing` gives it, or an accent override leaves the selection ring the one thing on screen still showing the old colour. |
|
||||
|
||||
### Authoring a new scheme
|
||||
|
||||
Look at `settings_state.dart` in the host app, not the package — that's where
|
||||
the actual eleven-scheme palette lives (the package only defines the *type*
|
||||
and the neutral fallback used in tests/demos). Each scheme is grouped under
|
||||
`// ── Accounted for ──` vs `// ── not reviewed yet ──` comments — that's a
|
||||
live audit trail, not decoration; keep using it as new tokens get added so
|
||||
it's visible which colours were deliberately chosen vs. still riding an old
|
||||
default.
|
||||
|
||||
## Density & spacing
|
||||
|
||||
`Density` (`theme/theme_data.dart`) is the **only** place pixel heights get
|
||||
hand-set. Everything else — icon size, padding, line-box height — is a
|
||||
derived getter. The class doc lays out the chain in full; the short version:
|
||||
|
||||
- You set `controlHeight` (23 compact / 27 normal), `fontSize` (10, both
|
||||
densities), `lineHeight` (1.1, both densities).
|
||||
- `lineBox = (fontSize * lineHeight).roundToDouble()` falls out of those.
|
||||
- `iconSize = lineBox` — a control icon always matches the text beside it.
|
||||
- `controlPaddingY = (controlHeight - lineBox) / 2` — padding is *derived
|
||||
from* the height target, never the other way round.
|
||||
|
||||
**Never hand-type a control height or a vertical padding.** If a number needs
|
||||
tuning, it belongs in `Density`, not at the call site — that's exactly the
|
||||
mistake the class doc says produced four different control heights and a
|
||||
stray `Transform.translate` before this system existed.
|
||||
|
||||
Two densities are in active use: `ButtonDensity.compact` (54 call sites) is
|
||||
the default for tool chrome — toolbars, menus, panels. `ButtonDensity.normal`
|
||||
(5 call sites) shows up for things meant to feel less cramped — e.g. the
|
||||
`Button.primary` "confirm this row" pattern in `blender_properties_section.dart`
|
||||
via `ButtonDensity.fromTheme(theme)`. Default to compact unless you have a
|
||||
specific reason not to.
|
||||
|
||||
Two spacing scales, don't mix them up:
|
||||
- `controlGap` — INSIDE a control (icon-to-label gap). Tighter, on purpose.
|
||||
It's also the base unit the gap scale below is derived from.
|
||||
- `containerGap` / `containerPadding` — between controls in a panel/popover/
|
||||
dialog, and the padding inside one.
|
||||
|
||||
### The gap scale
|
||||
|
||||
Layout spacing between widgets comes off `theme.density`, not a literal:
|
||||
|
||||
| Step | compact | normal | Use it for |
|
||||
|---|---|---|---|
|
||||
| `gapXxs` | 2 | 3 | hairline — a label sat directly above its value |
|
||||
| `gapXs` | 4 | 5 | tight — icon-adjacent, or items reading as one unit |
|
||||
| `gapSm` | 6 | 8 | snug |
|
||||
| `gapMd` | 8 | 10 | **the default** — related but distinct |
|
||||
| `gapLg` | 12 | 15 | section-level, within a panel or form |
|
||||
| `gapXl` | 16 | 20 | between major blocks |
|
||||
| `gapXxl` | 24 | 30 | page-level |
|
||||
|
||||
```dart
|
||||
const Gap.md(), // <- this, not Gap(8)
|
||||
```
|
||||
|
||||
`Gap` has a named constructor per step. They resolve their extent from the
|
||||
theme at build time, so a call site stays `const` and still tracks the density
|
||||
- no `GarageTheme.of(context)` needed in a build method just because it
|
||||
contains a gap. `Gap(n)` with a literal still works and still wins, for the
|
||||
rare thing that genuinely isn't on the scale.
|
||||
|
||||
When in doubt, reach for `gapMd`. It's the same 8px `Gap(8)` was, so the old
|
||||
advice hasn't changed — it just has a name now, and it moves when the density
|
||||
does instead of staying 8 forever.
|
||||
|
||||
`gapMd` equals `containerGap` and `gapXl` equals `containerPadding` at both
|
||||
densities. That's not arranged, it's what those two were already set to, which
|
||||
is the evidence `controlGap` is the right base unit —
|
||||
`test/density_gap_scale_test.dart` holds it to that.
|
||||
|
||||
**Reaching for a literal is now the exception, not the rule.** Before the
|
||||
scale existed the two Garage web frontends had drifted to sixteen distinct gap
|
||||
values between them, including a 3, a 5 and ten 14s. If a step doesn't fit,
|
||||
that's worth a conversation about the scale rather than a one-off number.
|
||||
|
||||
## Buttons — variant semantics
|
||||
|
||||
`Button` and `IconButton` both expose five named constructors:
|
||||
`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`. Real usage
|
||||
across both apps settles into a clear pattern — this is the single most
|
||||
useful thing in this document:
|
||||
|
||||
| Variant | Meaning | Evidence |
|
||||
|---|---|---|
|
||||
| **ghost** | Default, lowest-emphasis action. Toolbar icons, dialog close (X) buttons, settings-cog buttons. Most common `IconButton` variant by a wide margin (15 sites). | `pane_dialog.dart` close button, `blender_editor.dart` header icons |
|
||||
| **outline** | Second most common (`Button.outline`: 14 sites). The "resting/inactive" half of a toggle pair, AND the standard "Cancel"/dismissive action in a dialog action row. | `export_dialog_widgets.dart`: `Button.outline(onPressed: onCancel, child: Text("Cancel"))` |
|
||||
| **secondary** | Neutral filled control — NOT a toggle's resting state (that's outline), more like "has a job but isn't the emphasised one." An icon button glued onto a text field (browse/file-picker button in a `ButtonGroup`) — see *Properties* below; the field goes `TextFieldVariant.secondary` to match, and reaching for `outline` here is the usual slip. Also used as a toggle's resting state in a couple of places (Music Maker's transport controls) — outline and secondary are somewhat interchangeable for "not active right now," pick whichever reads better against the surrounding controls. | `export_dialog_widgets.dart` browse button, Music Maker transport |
|
||||
| **primary** | The accent colour. Two jobs: (1) the *engaged* half of a toggle-button pair — `condition ? primary : outline` is the standard toggle idiom, used repeatedly (`_SnapPopoverButton`, `draw_panel.dart` eyedropper, Music Maker play/pause); (2) the single confirm/CTA action in a dialog, almost always via the `PrimaryButton` shorthand rather than `Button.primary` directly. | `_open ? IconButton.primary(...) : IconButton.outline(...)` |
|
||||
| **destructive** | Reserved for genuinely dangerous/irreversible actions — delete, record. Rare on purpose (3 sites total). Don't reach for it just because something is "important." | Music Maker's record toggle: `recording ? IconButton.destructive(...) : IconButton.secondary(...)` |
|
||||
|
||||
**The toggle-button recipe** (this exact shape appears in every editor):
|
||||
|
||||
```dart
|
||||
active
|
||||
? IconButton.primary(
|
||||
density: ButtonDensity.compact,
|
||||
icon: Icon(LucideIcons.some_icon).iconSmall,
|
||||
onPressed: onToggle,
|
||||
)
|
||||
: IconButton.outline(
|
||||
density: ButtonDensity.compact,
|
||||
icon: Icon(LucideIcons.some_icon).iconSmall,
|
||||
onPressed: onToggle,
|
||||
)
|
||||
```
|
||||
|
||||
**The dialog action-row recipe** (`export_dialog_widgets.dart`, verbatim shape):
|
||||
|
||||
```dart
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Button.outline(onPressed: onCancel, child: const Text("Cancel")),
|
||||
const Gap(8),
|
||||
PrimaryButton(onPressed: enabled ? onConfirm : null, child: Text(confirmLabel)),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
There are also bare `PrimaryButton` / `SecondaryButton` / `OutlineButton` /
|
||||
`GhostButton` / `DestructiveButton` widgets (no `.constructor` dot-syntax) —
|
||||
lighter-weight wrappers around the same variants. `PrimaryButton` specifically
|
||||
is the idiomatic way to write a dialog's confirm button, over `Button.primary`.
|
||||
|
||||
## Icons
|
||||
|
||||
- Source: `flutter_lucide`, re-exported through `garage_ui.dart` as
|
||||
`LucideIcons`. **Names are snake_case** (`LucideIcons.file_plus`,
|
||||
`LucideIcons.chevron_right`) — this is `flutter_lucide`'s native spelling,
|
||||
used directly. There is no camelCase shim in this package.
|
||||
- Three tiers: `.iconSmall` (`density.iconSize`), `.iconMedium` (20px),
|
||||
`.iconLarge` (24px). Set size via the extension, never a hardcoded `size:`.
|
||||
- **`Button` and `IconButton` already apply `small` to everything inside
|
||||
them** — `leading`, `trailing` and the child — so a bare `Icon` in a button
|
||||
is correctly sized and needs no extension. Reach for one only to *override*
|
||||
that. Outside a button the ambient default is `medium` (20px), which is
|
||||
usually too big for a control-adjacent icon; that's where a bare `Icon` does
|
||||
go wrong.
|
||||
- `small` is `1.1 × fontSize` — 13px at product, 11px at compact/normal — not
|
||||
the line box. It was the line box, which is tidy for layout (an icon-only
|
||||
control matches a text control's height for free) and wrong for the eye:
|
||||
lucide glyphs fill their box nearly edge to edge while a 12px font caps out
|
||||
around 8.5px, so a line-box icon read ~70% taller than the text next to it.
|
||||
Control height is unaffected either way — that comes from `controlHeight`.
|
||||
- Muted/de-emphasised icon: chain `.muted()` after the size extension —
|
||||
`Icon(LucideIcons.chevron_left).iconSmall.muted()`.
|
||||
|
||||
## Typography
|
||||
|
||||
`theme.typography` exposes `normal` / `medium` (weight-only styles) and
|
||||
`small` (the shared control font — size and line-height come from `Density`,
|
||||
never set this by hand). Two font-family helpers:
|
||||
|
||||
- `theme.typography.sansStyle(style)` — Geist, the UI's default face.
|
||||
- `theme.typography.monoStyle(style)` — Geist Mono. Reach for this
|
||||
specifically for **numeric readouts**: perf stats, transport time,
|
||||
coordinates — anywhere the content is a number that benefits from
|
||||
fixed-width digits. Not for general UI text.
|
||||
|
||||
Text-widget shorthands (`Widget` extensions, merge over ambient
|
||||
`DefaultTextStyle`): `.xSmall()` / `.small()` / `.large()`, `.medium()` /
|
||||
`.semiBold()` / `.bold()`, `.muted()`.
|
||||
|
||||
### The text ladder
|
||||
|
||||
The three size shorthands come off `density`, not literals:
|
||||
|
||||
| Shorthand | Token | compact / normal | Use it for |
|
||||
|---|---|---|---|
|
||||
| `.xSmall()` | `textXs` | 11 | dialog copy, headings, hints — and the ambient body size |
|
||||
| `.small()` | `textSm` | 14 | body copy a step above the controls |
|
||||
| `.large()` | `textLg` | 18 | headings |
|
||||
|
||||
`textXs` sits one step above `density.fontSize` — 11 against a 10px control
|
||||
font — and is also what `GarageTheme` sets as the ambient body size, so page
|
||||
copy and dialog copy are the same size by construction.
|
||||
|
||||
It was `x1.2` (12) for a while, which put every dialog title and page heading a
|
||||
fifth above the controls beneath them. That read as oversized rather than as
|
||||
hierarchy — weight and colour carry the emphasis, not size.
|
||||
|
||||
What was wrong before is that the two were *unlinked*: these were hardcoded
|
||||
`12`/`14`/`18` multiplied by `scaling`, while the control font comes off
|
||||
`Density` and is deliberately not scaled. The intended 12-vs-10 held at scaling
|
||||
1.0 and drifted to 14.4-vs-10 at 1.2 — the ratio moved with scaling. Deriving
|
||||
them pins it, and makes `density.fontSize` the single knob that moves every
|
||||
piece of app text together.
|
||||
|
||||
`scaling` is deliberately **not** applied to these. It still moves the
|
||||
medium/large icon tiers, border widths and `GarageTheme`'s own
|
||||
`DefaultTextStyle` — but text that has to line up with a control cannot be on
|
||||
a different axis from the control.
|
||||
|
||||
## Structural widgets
|
||||
|
||||
- **`Panel`** — the bordered, rounded surface for docked tool content. Two
|
||||
modes: standalone (tracks its own hover) or controlled (`active: bool`,
|
||||
parent drives it — used when multiple panels need to be mutually exclusive,
|
||||
e.g. only one lit at a time in a Blender-style three-pane layout). Border
|
||||
goes from `panelBorder` to `panelBorderHighlighted` when active/hovered.
|
||||
- **`ChromeBar`** — the shared treatment for a header/footer strip: fixed
|
||||
height, `chrome` background, padding. Both the header and footer in an
|
||||
editor should be built from this rather than a raw `Container`, so they
|
||||
stay pixel-identical in height (`kChromeBarHeight`, shared constant).
|
||||
- **`EditorShell`** — header / center / optional left+right docks / footer,
|
||||
stacked as one frame. The outermost layout of a whole editor screen.
|
||||
- **`GarageShell`** — main content + two stacked sidebar panes with a
|
||||
draggable resize handle between main and sidebar, plus centralised hover
|
||||
(only one of the three panes lit at once). This is the Blender-style
|
||||
three-pane editor shell, factored out so it isn't hand-rolled per app.
|
||||
- **`ButtonGroup`** — lays controls out in a row (or column) and zeroes the
|
||||
corners where they touch, so they read as one connected control rather than
|
||||
two things that happen to be adjacent. A field with a button welded to its
|
||||
end (password + edit, path + browse, input + unit) is a `ButtonGroup`, not a
|
||||
`Row` with a `Gap` in it. Nested groups merge rather than shadow — an inner
|
||||
group can be told to drop both its top and its start edge, which is how a
|
||||
stacked field/eye/button cluster avoids a doubled stroke down the seam.
|
||||
|
||||
Watch `expands`. It defaults to `false`, which wraps the flex in an
|
||||
`IntrinsicHeight`; the group stretches its children on the cross axis, so
|
||||
without that wrapper it needs a bounded height from its parent and throws
|
||||
`BoxConstraints forces an infinite height` when it doesn't get one. Only
|
||||
pass `expands: true` when the parent already gives it a height.
|
||||
|
||||
- **`Card`** vs **`SurfaceCard`** — `Card` always draws its own
|
||||
`OutlinedContainer` (border + fill). `SurfaceCard` additionally understands
|
||||
sheet-overlay context: inside a sheet it collapses to just padding, because
|
||||
the sheet is already the surface and a nested card would double up the
|
||||
border. Default to `Card` unless the content might end up inside a sheet.
|
||||
- **`OutlinedContainer`** — the base primitive `Card`/`Panel` build on:
|
||||
border + radius + optional shadow. Reach for it directly for one-off
|
||||
floating chrome that isn't quite a card — e.g. a mobile slide-in panel:
|
||||
|
||||
```dart
|
||||
OutlinedContainer(
|
||||
borderColor: GarageTheme.of(context).colorScheme.border,
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0xff000000).withValues(alpha: 0.15), blurRadius: 4, spreadRadius: 2),
|
||||
],
|
||||
child: ...,
|
||||
)
|
||||
```
|
||||
|
||||
A bigger, "floating well above the app" shadow (splash/welcome overlay)
|
||||
goes heavier and uses negative spread to keep the blur from reading as a
|
||||
hard edge: `blurRadius: 40, spreadRadius: -8, offset: Offset(0, 20)`, alpha
|
||||
`0.4`. Scale shadow weight to how far off the page the thing is meant to
|
||||
read as floating — a docked panel border shadow and a modal-over-everything
|
||||
shadow should not look like the same intensity.
|
||||
|
||||
## Properties — the settings-row system
|
||||
|
||||
Any screen that is a list of *things you can change* is built from
|
||||
`PropertiesSection` + `PropertyRow`. This covers settings panes, inspectors,
|
||||
and account screens. Do not assemble one out of `Column` + `Text` + a
|
||||
divider — the split alignment, the collapse behaviour, the actions band and
|
||||
the row minimum height are all in here already.
|
||||
|
||||
- **`PropertiesSection`** — a titled, collapsible block: `title`, `subtitle`,
|
||||
`rows`, an optional `actions` band along the bottom for section-level
|
||||
buttons (Save / Reset / Refresh), and `collapsed` + `onToggle` driven by the
|
||||
parent so several sections can be remembered independently.
|
||||
- **`PropertyRow`** — one setting. `label` on the left of the split, `child`
|
||||
(the control) on the right. `split` is the fraction of the width sitting
|
||||
left of that line, so every row in a section lines its controls up at the
|
||||
same x. `labelless: true` for a row that has no name of its own.
|
||||
|
||||
### `subtitle` vs `description` — the one that gets got wrong
|
||||
|
||||
Both are muted second lines. They are not interchangeable, and picking the
|
||||
wrong one is the single most common mistake against this component:
|
||||
|
||||
| | Where it renders | What it's for |
|
||||
|---|---|---|
|
||||
| **`subtitle`** | Inside the **label column**, under the label, holding the same right-alignment against the split | *Naming the value.* "Last used 3d ago", "Never used", "2 permissions · last used 5m ago" — text that says **which** row this is |
|
||||
| **`description`** | **Full width** under the whole row, spanning label *and* control | *Explaining the setting.* Consequences, caveats, what changes when you change it — "Permanently remove this account… This cannot be undone." |
|
||||
|
||||
The test: does the sentence identify **this particular item** (subtitle), or
|
||||
does it explain **what the control does** (description)? A list of five
|
||||
passkeys wants five subtitles, not five full-width paragraphs.
|
||||
|
||||
**Never put explanatory copy in `child`.** It is the single failure mode this
|
||||
component has. A paragraph in the control column shares a cell with the
|
||||
control, so the copy wraps to three lines, the button gets squeezed against
|
||||
the right edge, and the section's split alignment stops meaning anything
|
||||
because every row's control now starts somewhere different. `child` is for
|
||||
the control. Copy goes in `description`.
|
||||
|
||||
```dart
|
||||
// WRONG - copy competing with the control for the same column
|
||||
PropertyRow(
|
||||
label: "Delete account",
|
||||
scheme: scheme,
|
||||
child: Row(children: [
|
||||
Expanded(child: Text("Permanently remove this account…").muted()),
|
||||
const Gap.md(),
|
||||
Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
|
||||
]),
|
||||
)
|
||||
|
||||
// RIGHT - the slot that already exists for it
|
||||
PropertyRow(
|
||||
label: "Delete account",
|
||||
scheme: scheme,
|
||||
description: "Permanently remove this account, all sign-in methods, and "
|
||||
"any OAuth grants. This cannot be undone.",
|
||||
child: Button.destructive(onPressed: onDelete, child: const Text("Delete…")),
|
||||
)
|
||||
```
|
||||
|
||||
A row that is *only* explanation and has no control at all is still a
|
||||
`PropertyRow` — give it the `description` and pass `const SizedBox.shrink()`
|
||||
as the child.
|
||||
|
||||
### Fields inside a property row
|
||||
|
||||
`TextField` takes a `variant`: `TextFieldVariant.outline` (default) or
|
||||
`.secondary`. Inside a properties pane, prefer `.secondary` — it matches the
|
||||
filled treatment the surrounding controls use, and it pairs with
|
||||
`ButtonStyle.secondary()` when a button is welded to the field in a
|
||||
`ButtonGroup`. An `outline` field next to a `secondary` button (or the
|
||||
reverse) reads as two controls from different screens.
|
||||
|
||||
A value the user is not allowed to edit — a password, a verified email — is
|
||||
still a field: `readOnly: true, enabled: false` with a stand-in value, not a
|
||||
bare `Text` floating in the column. It keeps the row's geometry and tells the
|
||||
reader "this is a value that lives here" rather than "this is a caption."
|
||||
|
||||
## The menu system — one model, two renderers
|
||||
|
||||
This is the pattern from the `AppMenuGroup` model in `app_menu.dart`, and
|
||||
it's worth calling out on its own because it's easy to accidentally
|
||||
reinvent per-app (it has been, twice):
|
||||
|
||||
Define the menu once as data — `List<AppMenuGroup>` (`AppMenuGroup` →
|
||||
`AppMenuAction` / `AppMenuCheck` / `AppMenuSeparator`) — not as widgets. Then:
|
||||
|
||||
1. **In-app render**: walk the model into `Menubar`/`MenuButton`/
|
||||
`MenuCheckbox`/`MenuDivider` widgets (see `_toMenuItem`/`_menuItemsFor` in
|
||||
either app's menu code for the translation).
|
||||
2. **Native macOS bar**: `AppMenuNativeRenderer.build(menus, appName: ...)`
|
||||
pushed into an `AppMenuNotifier` sitting above the app's `Router`, via
|
||||
`PlatformMenuHost`.
|
||||
|
||||
Both renderers must consume the **same** computed `menus` value from the
|
||||
**same** build pass — not two independent calls to whatever builds the model.
|
||||
Two separate computations drift: they'll watch slightly different state,
|
||||
recompute at different times, and eventually disagree about what's checked or
|
||||
what a shortcut is (this exact bug shipped and had to be fixed). Compute
|
||||
once, hand the value to both. Gate the native push on
|
||||
`AppMenuNativeRenderer.signature(menus)`, not the list itself — building a
|
||||
fresh `AppMenuGroup` tree (and fresh `SingleActivator`s inside it) on every
|
||||
build is normal and fine, but pushing to the native bar on every build is not
|
||||
— the signature is what turns "recomputed every frame" into "pushed only
|
||||
when it actually changed."
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **No `package:flutter/material.dart`.** Not even for `Colors.black`. Use
|
||||
`Color(0xff000000)` / `Color(0xffffffff)` — literal, not `Colors.*`. The UI
|
||||
kit is deliberately `WidgetsApp`-based, not `MaterialApp`-based, so an app
|
||||
built on it shouldn't reach for Material either.
|
||||
- **Don't hand-type a control height, icon size, or vertical padding.** It
|
||||
belongs in `Density` as a named field with everything else derived from it.
|
||||
- **Don't build `chrome`/`panel` treatment from raw `Container` + hardcoded
|
||||
colour.** Use `ChromeBar`/`Panel`/`EditorShell`/`GarageShell` — they read
|
||||
the right token (and in chrome's case, the right *unaccented* scheme) for
|
||||
you.
|
||||
- **Don't hand-roll a settings row.** `PropertiesSection` + `PropertyRow`
|
||||
exist, and a `Column` of `Text` + control + divider will silently lose the
|
||||
split alignment, the collapse state and the actions band. If you find
|
||||
yourself writing `Row(children: [Expanded(Text(...)), Gap, Button])` inside
|
||||
a settings pane, you want `description:` instead.
|
||||
- **Don't glue a button to a field with a `Gap`.** That's a `ButtonGroup` —
|
||||
it merges the touching corners so the pair reads as one control.
|
||||
- **Don't build a menu as widgets directly.** Model it as `AppMenuGroup` data
|
||||
first (see above), even if there's currently only one renderer consuming
|
||||
it — a native menu bar tends to get added later, and retrofitting a model
|
||||
under existing widget-only menu code is exactly the refactor that
|
||||
motivated this document.
|
||||
- **Don't reach for `destructive` for "this matters."** It means
|
||||
delete/discard/record — genuinely dangerous, genuinely irreversible.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Local development
|
||||
|
||||
How to edit the SDKs and an app at the same time, and see the change in the
|
||||
running app straight away — no push, no tag, no `ref:` bump while you iterate.
|
||||
|
||||
|
||||
## The setup
|
||||
|
||||
Your app keeps depending on the SDKs the normal way, as pinned git deps (see
|
||||
[Install](../README.md#install)):
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
garage_ui:
|
||||
git:
|
||||
url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git
|
||||
path: garage_ui
|
||||
ref: v0.1.0
|
||||
```
|
||||
|
||||
Leave that alone. Clone this repo somewhere, then next to the app's
|
||||
`pubspec.yaml` add a `pubspec_overrides.yaml` that points the packages you're
|
||||
working on at the local checkout:
|
||||
|
||||
```yaml
|
||||
# local garage sdks, so edits hot reload without a push. gitignored, never commit
|
||||
dependency_overrides:
|
||||
garage_ui:
|
||||
path: ../Garage-SDKs/garage_ui
|
||||
```
|
||||
|
||||
The path is relative to the app's folder, so adjust it to wherever your checkout
|
||||
actually lives. If there's a space anywhere in it, quote it:
|
||||
|
||||
```yaml
|
||||
path: "../../Documents/Projects/Garage Services/SDKs/garage_ui"
|
||||
```
|
||||
|
||||
pub reads `pubspec_overrides.yaml` on its own, you dont pass it anything. Run
|
||||
`flutter pub get` and you should see a line per override:
|
||||
|
||||
```
|
||||
! garage_ui 0.1.0 from path ../Garage-SDKs/garage_ui (overridden in ./pubspec_overrides.yaml)
|
||||
```
|
||||
|
||||
If that line isn't there, the override isn't active — check the path.
|
||||
|
||||
|
||||
## Overriding auth, entitlements or iap
|
||||
|
||||
`garage_entitlements` and `garage_iap` both depend on `garage_auth` by path
|
||||
(`path: ../garage_auth` in their pubspecs). So the moment you override either
|
||||
one, the local copy pulls in a local `garage_auth` too — and your app is still
|
||||
asking for the git one. pub sees `garage_auth` from two sources and refuses.
|
||||
|
||||
The rule: **if you override `garage_entitlements` or `garage_iap`, override
|
||||
`garage_auth` as well.**
|
||||
|
||||
```yaml
|
||||
dependency_overrides:
|
||||
garage_auth:
|
||||
path: ../Garage-SDKs/garage_auth
|
||||
garage_entitlements:
|
||||
path: ../Garage-SDKs/garage_entitlements
|
||||
garage_ui:
|
||||
path: ../Garage-SDKs/garage_ui
|
||||
```
|
||||
|
||||
`garage_ui` doesnt depend on any of the others, so it can be overridden on its
|
||||
own.
|
||||
|
||||
|
||||
## Hot reload
|
||||
|
||||
- Changed `pubspec_overrides.yaml` (added, removed, or edited a path)? Run
|
||||
`flutter pub get`, then do a full restart of the app. Hot reload wont pick up
|
||||
a dependency swap.
|
||||
- After that, edits inside the SDK checkout behave like your own app code. Save
|
||||
and hot reload.
|
||||
- Same caveats as app code: if the change is in something that only runs once
|
||||
(`main()`, initial state, a `const` widget tree), hot restart instead.
|
||||
|
||||
|
||||
## Gitignore it
|
||||
|
||||
Add this to the app's `.gitignore`:
|
||||
|
||||
```
|
||||
pubspec_overrides.yaml
|
||||
```
|
||||
|
||||
The path in it only exists on your machine. Commit it and every other checkout
|
||||
breaks, and so does any CI job or Docker image build, because none of them have
|
||||
your local SDK checkout sitting next to the app.
|
||||
|
||||
While the override is active, `pubspec.lock` records the path source instead
|
||||
of the git one. Thats expected. Just dont ship a lockfile in that state — see
|
||||
below.
|
||||
|
||||
Also: never edit the copies under `~/.pub-cache/git`. pub treats that folder as
|
||||
disposable and will overwrite or delete it without asking, and your app isn't
|
||||
necessarily even reading from the copy you changed. Edit a real checkout and
|
||||
override to it.
|
||||
|
||||
|
||||
## Shipping an SDK change
|
||||
|
||||
Once the change works locally:
|
||||
|
||||
1. Commit and push it in this repo.
|
||||
2. Tag a new version, e.g. `v0.1.1`, and push the tag.
|
||||
3. In each app that should get it, bump `ref:` to the new tag. Do it on purpose,
|
||||
per app — dont float `main`.
|
||||
4. Check the app builds **without** the override. Move it aside, resolve against
|
||||
the real tag, and analyze:
|
||||
|
||||
```sh
|
||||
mv pubspec_overrides.yaml pubspec_overrides.yaml.off
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
This is the step that catches a forgotten push, a tag on the wrong commit, or
|
||||
a `ref:` you missed. It also puts `pubspec.lock` back on the git source.
|
||||
5. Deploy, then move the override back if you're carrying on.
|
||||
|
||||
|
||||
## Running the SDKs' own tests
|
||||
|
||||
These have tests:
|
||||
|
||||
```sh
|
||||
cd garage_entitlements && flutter test
|
||||
cd garage_iap && flutter test
|
||||
cd garage_ui && flutter test
|
||||
```
|
||||
|
||||
`garage_auth` has no `test/` dir yet.
|
||||
|
||||
There are two examples, both minimal wiring references rather than full apps:
|
||||
|
||||
- `garage_auth/example/main.dart` — a single file showing `GarageAuth` wired
|
||||
into an app. It has no pubspec of its own.
|
||||
- `garage_iap/example` — a small package wiring `garage_auth` + `garage_iap`
|
||||
together by path. `flutter pub get` and `flutter analyze` in there is a quick
|
||||
way to check the two still fit together.
|
||||
@@ -0,0 +1,345 @@
|
||||
# Offline licences
|
||||
|
||||
How `garage_entitlements` checks a licence key, where it keeps them between
|
||||
runs, and what it takes to do the same thing without the Flutter package.
|
||||
There's a short bit on `garage_iap`'s older licence at the end, for contrast.
|
||||
|
||||
Everything here is read off the source in
|
||||
`garage_entitlements/lib/src/` — `jwks_verify.dart` for the checks,
|
||||
`key_cache.dart` for storage, `garage_entitlements.dart` for `refresh()` and
|
||||
`cached()`. If this doc and the code ever disagree, the code wins.
|
||||
|
||||
- [What a key is](#what-a-key-is)
|
||||
- [The checks](#the-checks)
|
||||
- [Where keys are kept](#where-keys-are-kept)
|
||||
- [refresh()](#refresh)
|
||||
- [cached(), and the subject](#cached-and-the-subject)
|
||||
- [Key rotation](#key-rotation)
|
||||
- [The device clock](#the-device-clock)
|
||||
- [Verifying without the package](#verifying-without-the-package)
|
||||
- [garage_iap's licence, for contrast](#garage_iaps-licence-for-contrast)
|
||||
|
||||
|
||||
## What a key is
|
||||
|
||||
One compact RS256 JWT per entitlement. The header carries `alg` and `kid`, the
|
||||
payload looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user-123",
|
||||
"iss": "https://pay.imbenji.net",
|
||||
"aud": "field-notes/pro",
|
||||
"project": "field-notes",
|
||||
"sku": "pro",
|
||||
"kind": "one_off",
|
||||
"mode": "live",
|
||||
"expires_at": "2026-10-03T00:00:00Z",
|
||||
"iat": 1790000000,
|
||||
"exp": 1790003600
|
||||
}
|
||||
```
|
||||
|
||||
| Claim | What it is |
|
||||
| --- | --- |
|
||||
| `sub` | the user id the key was minted for |
|
||||
| `iss` | who signed it |
|
||||
| `aud` | `"<project>/<sku>"` — one string, not an array |
|
||||
| `project`, `sku` | the same two things again, split out |
|
||||
| `kind` | `"one_off"` or `"subscription"` |
|
||||
| `mode` | `"live"` or `"sandbox"` |
|
||||
| `expires_at` | when the *entitlement* runs out, ISO 8601. Missing for something owned outright |
|
||||
| `iat`, `exp` | seconds since epoch. `exp` is the *key's* expiry |
|
||||
|
||||
`exp` is the access decision. The server clamps it to the entitlement's own
|
||||
expiry before signing, so a key cant outlive the thing it unlocks. `expires_at`
|
||||
is for a UI to show ("renews on the 3rd") — dont gate on it.
|
||||
|
||||
The only claims that get checked are `iss`, `aud`, `sub`, `mode` and `exp`.
|
||||
`project`, `sku` and `kind` are read into the `GarageKey` after the checks
|
||||
pass, and `expires_at` becomes `entitlementExpiresAt`.
|
||||
|
||||
|
||||
## The checks
|
||||
|
||||
`verifyKey()` in `jwks_verify.dart`. The order is fixed, and the first failure
|
||||
stops it — any failure means not entitled. Every failure is an
|
||||
`EntitlementsError` with a `code`, so a caller can treat them all the same.
|
||||
|
||||
Before the six proper checks there are two cheap ones: the token has to split
|
||||
into three parts (`bad_token`) and the header `alg` has to be `RS256`
|
||||
(`bad_alg`). No `none`, no HS256, no negotiating.
|
||||
|
||||
**1. Signature.** Look up the JWKS key whose `kid` matches the header's `kid`,
|
||||
rebuild the RSA public key from its `n` and `e`, and check a PKCS#1 v1.5
|
||||
SHA-256 signature over `header.payload`. That's pointycastle, the same RSA
|
||||
stack the store signs with — there's no JWT library in the package, on
|
||||
purpose. Only `kty: "RSA"` entries are considered.
|
||||
|
||||
A `kid` that isnt in the JWKS is refused (`kid_not_found`), not guessed at. The
|
||||
package wont try the other keys to see if one happens to work. The one
|
||||
exception: if the header has *no* `kid` at all, the first RSA key in the doc is
|
||||
used.
|
||||
|
||||
A bad signature is `bad_signature`. A mangled signature that makes pointycastle
|
||||
throw is logged and treated the same.
|
||||
|
||||
**2. `iss`.** Must equal the issuer the app was built with — the `issuer`
|
||||
constructor argument, which defaults to `kGarageLicenceIssuer`:
|
||||
|
||||
```dart
|
||||
const String kGarageLicenceIssuer = String.fromEnvironment(
|
||||
"GARAGE_LICENCE_ISSUER",
|
||||
defaultValue: "https://pay.imbenji.net",
|
||||
);
|
||||
```
|
||||
|
||||
So `--dart-define=GARAGE_LICENCE_ISSUER=...` overrides it at build time. The
|
||||
point is that it's a constant the app checks against. The token doesnt get to
|
||||
say who it is. Fails as `iss_mismatch`.
|
||||
|
||||
**3. `aud`.** Must be exactly `"<projectSlug>/<sku>"` — a single string. Right
|
||||
project wrong sku fails, right sku wrong project fails, an array fails.
|
||||
`aud_mismatch`.
|
||||
|
||||
**4. `sub`.** Must equal the signed-in user's id. Somebody elses key copied
|
||||
onto this device fails here. `sub_mismatch`. Where the expected `sub` comes
|
||||
from matters a lot offline, see [cached()](#cached-and-the-subject).
|
||||
|
||||
**5. `mode`.** Must equal the `mode` the `GarageEntitlements` was constructed
|
||||
with (`"live"` by default). A sandbox key never satisfies a live check, and the
|
||||
other way round. A missing `mode` fails too. `mode_mismatch`.
|
||||
|
||||
**6. `exp`.** A key with no `exp` is refused (`no_exp`). Otherwise, if the
|
||||
device clock is past it, `expired`.
|
||||
|
||||
If all of that passes you get a `GarageKey`. `has(sku)` then just looks in the
|
||||
verified set — and `key(sku)` re-checks `exp` every time it's asked, dropping a
|
||||
key that went stale while the app was running.
|
||||
|
||||
|
||||
## Where keys are kept
|
||||
|
||||
`KeyCache` is the seam. The default is `SecureKeyCache`, on
|
||||
`flutter_secure_storage` — the same place `garage_auth` keeps its tokens.
|
||||
|
||||
One blob per project, under `ge.keys.<projectSlug>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": { "pro": "<compact jwt>", "extras": "<compact jwt>" },
|
||||
"jwks": { "keys": [ { "kty": "RSA", "kid": "...", "n": "...", "e": "..." } ] }
|
||||
}
|
||||
```
|
||||
|
||||
The JWKS and the whole key set are written together, in one write. There's
|
||||
deliberately no "keys but no JWKS" state, because that's a pile of tokens you
|
||||
cant check.
|
||||
|
||||
What's on disk is the raw tokens, not a verdict. Every load re-verifies them.
|
||||
|
||||
Things worth knowing about the default store:
|
||||
|
||||
- A read that fails (or a blob that wont parse) is logged and treated as no
|
||||
cache.
|
||||
- A write or clear that fails is logged and **not** thrown. So a `refresh()`
|
||||
can succeed in memory and still not have persisted — the next cold start
|
||||
would see the old blob.
|
||||
|
||||
`MemoryKeyCache` is the in memory one, for tests or anywhere you dont want
|
||||
persistence:
|
||||
|
||||
```dart
|
||||
final ent = GarageEntitlements(
|
||||
auth: auth,
|
||||
projectSlug: "field-notes",
|
||||
apiBaseUrl: "https://pay.imbenji.net/api",
|
||||
cache: MemoryKeyCache(),
|
||||
);
|
||||
```
|
||||
|
||||
`clear()` deletes the blob and empties the in memory set. Call it on sign-out.
|
||||
The `sub` check would reject the old user's keys anyway, but there's no reason
|
||||
to leave them on disk.
|
||||
|
||||
|
||||
## refresh()
|
||||
|
||||
```
|
||||
GET <apiBaseUrl>/v1/licences?project=<projectSlug>[&ttl=<seconds>]
|
||||
```
|
||||
|
||||
with the signed-in user's bearer. The response carries the keys and the JWKS
|
||||
together:
|
||||
|
||||
```json
|
||||
{
|
||||
"licences": [ { "sku": "pro", "licence": "<compact jwt>", ... } ],
|
||||
"jwks": { "keys": [ ... ] }
|
||||
}
|
||||
```
|
||||
|
||||
What it does with it, in order:
|
||||
|
||||
1. No `jwks` object in the body is an error (`no_jwks`). Nothing changes.
|
||||
2. Work out the expected `sub` (see below).
|
||||
3. For each entry, take the sku from the token's own `aud` — not the envelope's
|
||||
`sku`, which is a convenience and ignored — and run the full
|
||||
[six checks](#the-checks) against the JWKS that came in the same response.
|
||||
A token with an `aud` it cant split into project/sku is `bad_token`.
|
||||
4. If **any** key fails, the whole refresh throws. The cache and the in memory
|
||||
set are left exactly as they were.
|
||||
5. Only once every key has verified: write the new blob (all keys + the new
|
||||
JWKS), then replace the in memory set and notify listeners.
|
||||
|
||||
Replace, not merge. A sku missing from the response is gone, from memory and
|
||||
from disk. That's how a cancelled subscription stops working the next time the
|
||||
app is online, rather than hanging on untill its key's `exp`. An empty
|
||||
`licences` list empties the set.
|
||||
|
||||
`ttl` asks for shorter keys. The server clamps it, so asking for longer than
|
||||
the project allows doesnt error, it just doesnt get you longer.
|
||||
|
||||
|
||||
## cached(), and the subject
|
||||
|
||||
`cached()` is the boot path: read the blob, verify each key, fill the set. It
|
||||
never fetches keys. But it does need the expected `sub` for check 4, and that
|
||||
is the one place it can need the network.
|
||||
|
||||
The subject comes from `_subject()`:
|
||||
|
||||
- The first time it's asked, it calls `auth.profile()`, which is the OIDC
|
||||
userinfo endpoint (plus the discovery document, if `garage_auth` hasnt
|
||||
fetched that yet this run). It takes `sub` out of the answer.
|
||||
- After that it's remembered on the `GarageEntitlements` instance, so later
|
||||
calls are free.
|
||||
|
||||
So what happens depends on whether that instance has resolved a subject yet:
|
||||
|
||||
- **It has** — say `refresh()` ran earlier this session. `cached()` is fully
|
||||
offline.
|
||||
- **It hasnt, and userinfo is reachable** — one round trip, then offline.
|
||||
- **It hasnt, and userinfo isnt reachable** — a cold start with no network is
|
||||
the usual one. `profile()` throws, `cached()` logs "cant resolve the subject",
|
||||
and **drops every key** from the in memory set. `has()` is false for
|
||||
everything.
|
||||
- **The user isnt signed in** — `profile()` returns null, there's no `sub`
|
||||
(`no_subject`), same result: every key dropped.
|
||||
|
||||
In the last two cases only the in memory set is emptied. The blob on disk is
|
||||
left alone, so a later `cached()` that can resolve the subject picks the keys
|
||||
straight back up.
|
||||
|
||||
Note that it's the first `cached()` on a fresh instance that makes the call, so
|
||||
"verifies whats on disk, zero network" in the README holds once the subject is
|
||||
known, not on a cold offline launch. If your app has to unlock features on a
|
||||
plane from a cold start, that's the path to plan around.
|
||||
|
||||
Once the subject is resolved, each cached key is checked on its own. A key that
|
||||
fails — expired is the normal case, but also rotated away, wrong user, wrong
|
||||
mode — is logged and dropped, and the rest are kept. One dead key doesnt take
|
||||
the set with it. The sku it's checked against is the map key it was stored
|
||||
under, so a token filed under the wrong sku fails `aud`.
|
||||
|
||||
The remembered subject lives as long as the instance and `clear()` doesnt reset
|
||||
it. If a different user can sign in without the app restarting, give them a new
|
||||
`GarageEntitlements` rather than reusing the old one.
|
||||
|
||||
|
||||
## Key rotation
|
||||
|
||||
Rotation needs nothing from the app.
|
||||
|
||||
The JWKS always arrives in the same response as the keys, and is cached with
|
||||
them, so the keys on disk are always paired with the JWKS they were checked
|
||||
against. While both old and new signing keys are in the published JWKS, keys
|
||||
signed by either verify (there's a test for exactly that). When a key is
|
||||
finally rotated out, any cached key still signed by it fails `kid_not_found` on
|
||||
the next `cached()` — and the next `refresh()` brings down fresh keys *and* the
|
||||
new JWKS together, so it sorts itself out the next time the app is online.
|
||||
|
||||
|
||||
## The device clock
|
||||
|
||||
Offline, `exp` is compared against the device clock, and the device clock can
|
||||
be wound back. That's an accepted limitation. The alternative is refusing to
|
||||
work without a network, which is the thing offline keys exist to avoid.
|
||||
|
||||
What bounds it is the key lifetime. The shorter the TTL, the more often the app
|
||||
has to come online and fetch fresh keys anyway.
|
||||
|
||||
|
||||
## Verifying without the package
|
||||
|
||||
If you're checking a key somewhere else — a backend, a CLI, another language —
|
||||
it's the same job.
|
||||
|
||||
**Getting keys and the JWKS.** The package gets both from one call:
|
||||
|
||||
```
|
||||
GET https://pay.imbenji.net/api/v1/licences?project=<projectSlug>
|
||||
Authorization: Bearer <the user's access token>
|
||||
```
|
||||
|
||||
`ttl=<seconds>` is optional. The body is `{"licences": [{"licence": "<jwt>", ...}], "jwks": {...}}`.
|
||||
`garage_entitlements` doesnt call a separate JWKS endpoint — it only ever uses
|
||||
the `jwks` that comes back in that body — so that's the one to use.
|
||||
|
||||
If you're holding a key you got some other way, you still need the JWKS that
|
||||
goes with it. Keep them together, like the package does.
|
||||
|
||||
**Checking one.** In this order, and treat any failure as not entitled:
|
||||
|
||||
0. Three dot-separated parts. Header `alg` is exactly `RS256` — reject anything
|
||||
else before looking further.
|
||||
1. Find the JWKS entry with `kty: "RSA"` and a `kid` equal to the header's
|
||||
`kid`. Not found = reject. Dont fall back to trying every key. Verify
|
||||
RSASSA-PKCS1-v1_5 with SHA-256 over the ASCII bytes of
|
||||
`<header>.<payload>` (the base64url strings as they are in the token), using
|
||||
the public key from base64url `n` and `e`.
|
||||
2. `iss == "https://pay.imbenji.net"`. Hard-code it. Dont read it from the
|
||||
token and trust it.
|
||||
3. `aud == "<project>/<sku>"`, as a plain string, for the project and sku
|
||||
you're gating.
|
||||
4. `sub ==` the user you think you're talking to, from your own session — not
|
||||
from the token.
|
||||
5. `mode ==` `"live"` (or `"sandbox"` while the project is in sandbox). Missing
|
||||
is a fail.
|
||||
6. `exp` present, and now is before it.
|
||||
|
||||
Most JWT libraries will do 1, 2, 3 and 6 for you if you give them the JWKS, pin
|
||||
the algorithm to RS256 and tell them the issuer and audience. `sub` and `mode`
|
||||
you check yourself.
|
||||
|
||||
On a server you have a proper clock and a network, so the offline caveats dont
|
||||
apply — and if all you want is "does this user own it right now", the
|
||||
`/v1/entitlements` ledger is the truth anyway. The key is for when you cant
|
||||
ask.
|
||||
|
||||
|
||||
## garage_iap's licence, for contrast
|
||||
|
||||
`garage_iap` predates all of this and works differently. One licence JWT for
|
||||
the whole app, not one per entitlement:
|
||||
|
||||
- Fetched from `GET <apiBaseUrl>/v1/licence?app=<appSlug>`, with the JWKS
|
||||
fetched separately from `GET <apiBaseUrl>/v1/licence/jwks.json` in the same
|
||||
trip.
|
||||
- Its payload has an `app` claim and a `products` claim — a list of
|
||||
`{sku, kind, expires_at}` for everything the user owns in that app.
|
||||
- Cached as `{token, jwks}` under `gi.licence.<appSlug>` via the
|
||||
`LicenceCache` seam (`SecureLicenceCache`, or `MemoryLicenceCache` for
|
||||
tests). It's only written after it verifies.
|
||||
|
||||
`verifyLicence()` checks less: `RS256`, the signature by `kid` (same lookup
|
||||
rules as above), then `sub`, then `app`, then `exp`. There's no `iss`, no
|
||||
`aud`, and no `mode` check. `has(sku)` then asks whether the sku is anywhere in
|
||||
`products` — the per product `expires_at` isnt looked at, only the licence's
|
||||
own `exp`.
|
||||
|
||||
That's the wallet problem the per-entitlement keys were built to avoid: a
|
||||
lock that only cares about one feature gets handed the list of everything the
|
||||
user owns. It also resolves the subject through `profile()` the same way, so
|
||||
the same cold-offline caveat applies — except there a failure to resolve it
|
||||
throws out of `cachedLicence()` rather than quietly emptying the set.
|
||||
@@ -0,0 +1,473 @@
|
||||
# Platform setup
|
||||
|
||||
The bits of wiring that live in your app's runner folders, entitlements and
|
||||
manifests rather than in Dart. None of it is something a package can do for
|
||||
you, which is why it's all in one place here.
|
||||
|
||||
- [Redirect URIs](#redirect-uris)
|
||||
- [Catching the callback on native](#catching-the-callback-on-native)
|
||||
- [Token storage](#token-storage)
|
||||
- [macOS network access](#macos-network-access)
|
||||
- [garage_iap and Stripe](#garage_iap-and-stripe)
|
||||
- [garage_ui's macOS plugin](#garage_uis-macos-plugin)
|
||||
- [The pub bug with git + path deps](#the-pub-bug-with-git--path-deps)
|
||||
|
||||
|
||||
## Redirect URIs
|
||||
|
||||
### Registering the client
|
||||
|
||||
Your app is a **public** PKCE client. `garage_auth` never sends a
|
||||
`client_secret` — it cant, anything shipped in an app binary isnt a secret.
|
||||
Create the client under your project in the hub (or over the API with
|
||||
`"is_public": true`) and list every redirect URI the app will ever send.
|
||||
|
||||
What Garage lets you register:
|
||||
|
||||
| Shape | Example | For |
|
||||
| --- | --- | --- |
|
||||
| `https://` | `https://fieldnotes.example/auth/callback` | web |
|
||||
| `http://` on loopback only | `http://localhost:8765/auth/callback` | local dev |
|
||||
| custom scheme | `fieldnotes://auth/callback` | native + desktop |
|
||||
|
||||
The `redirect_uri` sent to `/authorize` has to match a registered one
|
||||
character for character. There are no wildcards. The one thing that floats is
|
||||
the **port on a loopback URL** — register `http://localhost:8765/auth/callback`
|
||||
and `flutter run -d chrome` on whatever random port it picks will still match.
|
||||
Scheme, host and path still have to be exact.
|
||||
|
||||
`garage_auth` sends the same resolved URI to `/authorize` and again in the
|
||||
token exchange, so if one works the other will too.
|
||||
|
||||
### Web ignores your scheme and host
|
||||
|
||||
On web, `redirect_web.dart` throws away the scheme and host of the
|
||||
`redirectUri` you passed and uses `window.location.origin` instead. Only the
|
||||
**path** is kept. So with
|
||||
|
||||
```dart
|
||||
GarageAuth(redirectUri: "https://fieldnotes.example/auth/callback", ...)
|
||||
```
|
||||
|
||||
a build served from `https://beta.fieldnotes.example` sends
|
||||
`https://beta.fieldnotes.example/auth/callback`. That's deliberate, the IdP
|
||||
bounces back to the same deployment the user started on. The catch is that
|
||||
**every origin you deploy to needs its own registered redirect URI** — prod,
|
||||
staging, a preview domain, all of them. Localhost is covered by the loopback
|
||||
port rule above.
|
||||
|
||||
How the path gets picked:
|
||||
|
||||
- A schemeless value (`/auth/callback`) or an `http(s)` URL — its path is used.
|
||||
- A custom scheme URI — falls back to `/auth/callback`.
|
||||
|
||||
That fallback exists because of a real gotcha. In `garagepay://auth/callback`
|
||||
the `auth` bit is the **host**, not part of the path, and the path is just
|
||||
`/callback`. Taking the path off it used to produce
|
||||
`https://host/callback`, which nobody had registered, and sign-in died with
|
||||
`redirect_uri not registered`. So a custom-scheme URI on web always means
|
||||
`/auth/callback`, whatever you wrote after the scheme.
|
||||
|
||||
If you want web to land somewhere other than `/auth/callback`, pass a web
|
||||
shaped value when you're on web:
|
||||
|
||||
```dart
|
||||
final auth = GarageAuth(
|
||||
issuer: "https://hub.imbenji.net/auth-api",
|
||||
clientId: "field-notes",
|
||||
redirectUri: kIsWeb ? "/signed-in" : "fieldnotes://auth/callback",
|
||||
);
|
||||
```
|
||||
|
||||
and make sure that path is both a route in your app and registered on the
|
||||
client, per origin.
|
||||
|
||||
### Native uses it verbatim
|
||||
|
||||
On iOS, Android, macOS, Windows and Linux (`redirect_io.dart`) the configured
|
||||
URI is used exactly as given. `signIn()` opens the authorize URL in the
|
||||
**external** browser via `url_launcher` (`LaunchMode.externalApplication`) and
|
||||
returns straight away. Garage redirects the browser to your custom scheme, the
|
||||
OS hands that to your app, and your app has to catch it and call
|
||||
`completeSignIn(uri.queryParameters)`. Nothing in `garage_auth` listens for
|
||||
the link itself.
|
||||
|
||||
|
||||
## Catching the callback on native
|
||||
|
||||
Two jobs: tell the OS your app owns the scheme, and listen for the link in
|
||||
Dart. [`app_links`](https://pub.dev/packages/app_links) does the listening on
|
||||
every platform and its per-platform docs are the reference for the runner
|
||||
changes — the snippets below are from those docs, check them against the
|
||||
version you actually resolve.
|
||||
|
||||
The Dart side:
|
||||
|
||||
```dart
|
||||
final appLinks = AppLinks(); // singleton, make it early so the cold-start link isnt missed
|
||||
|
||||
appLinks.uriLinkStream.listen((uri) async {
|
||||
if (uri.scheme != "fieldnotes") return;
|
||||
try {
|
||||
await auth.completeSignIn(uri.queryParameters);
|
||||
} catch (e, st) {
|
||||
print("sign in callback failed: $e\n$st");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`uriLinkStream` delivers the initial link as well as later ones.
|
||||
|
||||
From Flutter 3.24 Flutter's own deep link handling has to be switched off or it
|
||||
fights `app_links` for the link. That's the `FlutterDeepLinkingEnabled` /
|
||||
`flutter_deeplinking_enabled` lines below.
|
||||
|
||||
### iOS
|
||||
|
||||
`ios/Runner/Info.plist`, inside the top `<dict>`:
|
||||
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>fieldnotes</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>fieldnotes</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<false/>
|
||||
```
|
||||
|
||||
`app_links` 7 on iOS needs Flutter 3.38.1 or newer, and supports both the
|
||||
app-delegate and the newer scene lifecycle.
|
||||
|
||||
### macOS
|
||||
|
||||
Same `CFBundleURLTypes` block, in `macos/Runner/Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>fieldnotes</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>fieldnotes</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
`android/app/src/main/AndroidManifest.xml`, inside the `<activity>` for
|
||||
`.MainActivity`:
|
||||
|
||||
```xml
|
||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="fieldnotes" android:host="auth" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
Note the host. For `fieldnotes://auth/callback` the host is `auth` (same
|
||||
host-vs-path thing as the web gotcha above). You can drop `android:host` and
|
||||
match on the scheme alone, but keeping it cuts down on clashing with another
|
||||
app that picked the same scheme.
|
||||
|
||||
Test it without going through sign-in:
|
||||
|
||||
```sh
|
||||
adb shell am start -a android.intent.action.VIEW \
|
||||
-d "fieldnotes://auth/callback?code=x\&state=y"
|
||||
```
|
||||
|
||||
(`completeSignIn` will throw a state mismatch on that, which is fine, it proves
|
||||
the link arrived.)
|
||||
|
||||
### Windows
|
||||
|
||||
Windows doesnt read a manifest for a plain win32 app, the scheme has to go in
|
||||
the registry, and `app_links` wont do that for you. Two routes, both in the
|
||||
`app_links` Windows doc:
|
||||
|
||||
- **Packaged with [`msix`](https://pub.dev/packages/msix)** — add
|
||||
`protocol_activation: fieldnotes` under `msix_config` and the installer
|
||||
registers (and on uninstall, removes) it. Only works for the packaged app,
|
||||
not while debugging.
|
||||
- **Unpackaged** — write `HKCU\Software\Classes\fieldnotes` yourself, with an
|
||||
empty `URL Protocol` value and `shell\open\command` set to
|
||||
`"<path to exe>" "%1"`. The doc has a `win32_registry` snippet for it.
|
||||
|
||||
You also want the link going to the instance thats allready running (the one
|
||||
waiting on sign-in), not a fresh one. In `windows/runner/main.cpp`:
|
||||
|
||||
```cpp
|
||||
#include "app_links/app_links_plugin_c_api.h"
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||
if (SendAppLinkToInstance()) {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Two halves again. The scheme is registered by whatever installs the app — a
|
||||
`.desktop` entry with `x-scheme-handler/fieldnotes` in its mime types (if you
|
||||
build packages with `flutter_distributor`, that's `supported_mime_type` in
|
||||
`make_config.yaml`). And `linux/my_application.cc` has to become a single
|
||||
instance app that accepts the URL on the command line — the `app_links` Linux
|
||||
doc has the exact patch (present the existing window in `activate`, return
|
||||
`FALSE` from `local_command_line`, and swap `G_APPLICATION_NON_UNIQUE` for
|
||||
`G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN`). Copy it
|
||||
from there rather than from here, it touches three spots in the file.
|
||||
|
||||
|
||||
## Token storage
|
||||
|
||||
The default `SecureTokenStore` is `flutter_secure_storage`. Worth knowing
|
||||
before you debug anything: the store doesnt only hold tokens. `beginSignIn()`
|
||||
writes the PKCE verifier and `state` into it, and `completeSignIn()` reads them
|
||||
back. So a store that cant write, or one that forgets across the round trip,
|
||||
breaks **sign-in**, not just "stay signed in". On web the tab reloads at the
|
||||
callback, so a `MemoryTokenStore` there gives you a `State mismatch on OAuth
|
||||
callback` every time.
|
||||
|
||||
### macOS: unsigned builds cant use the Keychain
|
||||
|
||||
On macOS `flutter_secure_storage` wants the `keychain-access-groups`
|
||||
entitlement, in both `DebugProfile.entitlements` and `Release.entitlements`.
|
||||
A useful value for it involves `$(AppIdentifierPrefix)` — your team ID — which
|
||||
only exists when you sign with a real development certificate.
|
||||
|
||||
Ad-hoc signed (`CODE_SIGN_IDENTITY = "-"`, what you get with no team set), it
|
||||
goes wrong either way:
|
||||
|
||||
- **with** the entitlement, the build fails, there's no team for it to resolve
|
||||
against;
|
||||
- **without** it, every Keychain call returns `-34018`
|
||||
(`errSecMissingEntitlement`). Sign-in fails at the verifier stash and
|
||||
`restore()` finds nothing.
|
||||
|
||||
Fixes, pick one:
|
||||
|
||||
1. Sign the app properly (a team, a development cert) and add the entitlement.
|
||||
2. Pass your own `TokenStore` until you do:
|
||||
|
||||
```dart
|
||||
final auth = GarageAuth(
|
||||
issuer: ...,
|
||||
clientId: ...,
|
||||
redirectUri: ...,
|
||||
tokenStore: MyPrefsTokenStore(), // anything that implements read/write/delete
|
||||
);
|
||||
```
|
||||
|
||||
`flutter_secure_storage`'s own README also documents a third way on 10 and
|
||||
newer: `MacOsOptions(usesDataProtectionKeychain: false)` drops to the legacy
|
||||
Keychain, which doesnt need the entitlement. `SecureTokenStore` takes a
|
||||
`storage:` argument so you could hand it one configured like that. We havent
|
||||
leaned on it ourselves, so treat that as their claim, not ours.
|
||||
|
||||
Their README has one more macOS catch: Keychain Sharing needs a provisioning
|
||||
profile, and on a free Apple developer account Xcode embeds a machine specific
|
||||
one, so the built app only launches on the Mac that built it.
|
||||
|
||||
### Web compiled to wasm
|
||||
|
||||
`flutter build web --wasm` fails if the graph resolves `flutter_secure_storage`
|
||||
**9**. Its web half (`flutter_secure_storage_web` 1.x) is written against
|
||||
`dart:html`, which dart2wasm doesnt have. And a custom `TokenStore` doesnt save
|
||||
you — the generated web plugin registrant imports every web plugin in the
|
||||
dependency graph whether your code touches it or not, so it gets compiled
|
||||
anyway.
|
||||
|
||||
The packages allow `flutter_secure_storage: ">=9.2.2 <12.0.0"`. Make sure you
|
||||
resolve onto **10 or 11**, whose web half (`flutter_secure_storage_web` 2.x)
|
||||
doesnt use `dart:html`. If something else in your app is holding it on 9, pin
|
||||
it:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_secure_storage: ^10.0.0 # or ^11.0.0
|
||||
```
|
||||
|
||||
Knock-on effect on Android: `flutter_secure_storage` 10 raised its minimum to
|
||||
**API 23**. If your `minSdk` is lower, bump it.
|
||||
|
||||
On web the storage is best effort either way — the README calls its WebCrypto
|
||||
backed web implementation experimental.
|
||||
|
||||
|
||||
## macOS network access
|
||||
|
||||
A macOS app from `flutter create` runs in the App Sandbox, and the sandbox
|
||||
blocks outgoing connections untill you add the client entitlement. Every one
|
||||
of these packages talks to Garage over HTTP, so without it the first discovery
|
||||
fetch fails (usually as a `SocketException: Connection failed (Operation not
|
||||
permitted)`).
|
||||
|
||||
Add it to **both** `macos/Runner/DebugProfile.entitlements` and
|
||||
`macos/Runner/Release.entitlements`:
|
||||
|
||||
```xml
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
The debug profile file allready has `network.server` in it by default. That's
|
||||
incoming connections, so the flutter tools can talk to the running app — it
|
||||
does nothing for your requests. Dont remove it, and dont mistake it for the
|
||||
one you need. Check both files, it's easy to have one build working and the
|
||||
other not.
|
||||
|
||||
|
||||
## garage_iap and Stripe
|
||||
|
||||
`PurchaseMode.sheet` uses `flutter_stripe`'s PaymentSheet. `garage_iap` pins
|
||||
`flutter_stripe: ^11.1.0`, so these are the 11.x requirements from its README.
|
||||
|
||||
### Where the sheet is even tried
|
||||
|
||||
`sheet.dart` conditionally imports `sheet_io.dart` when `dart:io` exists and
|
||||
`sheet_stub.dart` otherwise:
|
||||
|
||||
- **iOS, Android** — the real sheet.
|
||||
- **macOS, Windows, Linux** — `sheet_io.dart` checks `Platform.isIOS ||
|
||||
Platform.isAndroid` and returns `unsupported`.
|
||||
- **Web** — the stub, always `unsupported`.
|
||||
|
||||
`unsupported` drops to the browser handoff. So does any **subscription**,
|
||||
because the server answers the payment-intent call with `fallback: "handoff"`.
|
||||
|
||||
### The setup is not optional on iOS / Android
|
||||
|
||||
Heads up, this one catches people out: the handoff fallback only covers
|
||||
platforms with **no** sheet. On iOS and Android the sheet is always attempted,
|
||||
and if the platform setup below is missing, `initPaymentSheet` /
|
||||
`presentPaymentSheet` fail. `sheet_io.dart` only swallows a
|
||||
`FailureCode.Canceled` — anything else is logged and rethrown, and `purchase()`
|
||||
throws. It doesnt fall back. If you cant do the setup, call
|
||||
`purchase(..., mode: PurchaseMode.handoff)` explicitly.
|
||||
|
||||
**iOS** — iOS 13 or above. In `ios/Podfile`:
|
||||
|
||||
```ruby
|
||||
platform :ios, '13.0'
|
||||
```
|
||||
|
||||
and match `IPHONEOS_DEPLOYMENT_TARGET` in the Xcode build settings. If you
|
||||
want card scanning, add an `NSCameraUsageDescription` to `Info.plist`.
|
||||
|
||||
**Android** — the Stripe Android SDK uses AppCompat UI and the support fragment
|
||||
manager for the sheet, so:
|
||||
|
||||
1. `MainActivity.kt` extends `FlutterFragmentActivity`, not `FlutterActivity`:
|
||||
|
||||
```kotlin
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
```
|
||||
|
||||
2. The activity theme is a descendant of `Theme.AppCompat`. In
|
||||
`android/app/src/main/res/values/styles.xml`, `LaunchTheme` becomes
|
||||
`parent="Theme.AppCompat.Light.NoActionBar"` and in `values-night/styles.xml`
|
||||
`parent="Theme.AppCompat.DayNight.NoActionBar"`. Stripe's example app sets
|
||||
`NormalTheme` to `parent="Theme.MaterialComponents"`.
|
||||
|
||||
3. minSdk 21+ (but see the API 23 note above), Kotlin 1.8.0+, Android Gradle
|
||||
plugin 8+.
|
||||
|
||||
4. Their ProGuard `-dontwarn` rules in `proguard-rules.pro`, and for 11.x
|
||||
`android.enableR8.fullMode=false` in `gradle.properties`. Copy both from the
|
||||
README of the exact version you resolved, the rule list has changed between
|
||||
versions.
|
||||
|
||||
None of this hot reloads. Do a full rebuild after.
|
||||
|
||||
The handoff itself only needs `url_launcher`, which works everywhere without
|
||||
setup (on macOS it still needs the network entitlement above, like
|
||||
everything else).
|
||||
|
||||
|
||||
## garage_ui's macOS plugin
|
||||
|
||||
`garage_ui` declares one native plugin, macOS only:
|
||||
|
||||
```yaml
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
macos:
|
||||
pluginClass: GarageUiPlugin
|
||||
```
|
||||
|
||||
`GarageUiPlugin` registers two method channels:
|
||||
|
||||
- **`garage/eyedropper`** — `isAvailable` and `pick`, backed by
|
||||
`NSColorSampler` (the system loupe, whole screen).
|
||||
- **`garage/cursor_lock`** — `lock` / `unlock`, hides and pins the cursor while
|
||||
you scrub a number field, and pushes raw `scrubDelta` calls back to Dart
|
||||
while locked.
|
||||
|
||||
It's picked up by the generated plugin registrant, so theres nothing to add to
|
||||
`MainFlutterWindow.swift`. The podspec targets macOS 10.15.
|
||||
|
||||
Everywhere else there's no native side, and the Dart side copes:
|
||||
|
||||
- **Eyedropper** — `eyedropper.dart` imports the web version when
|
||||
`dart.library.html` exists (JS web builds) and uses the browser `EyeDropper`
|
||||
API, which is Chromium only, feature checked. Everything else uses the native
|
||||
version, which answers `false` for "available" off macOS without touching the
|
||||
channel. Callers then fall back to sampling the app's own frame, which is
|
||||
window only but works everywhere. A wasm build has no `dart.library.html`,
|
||||
so it takes the native version: unavailable unless the browser reports
|
||||
macOS, in which case the channel call fails, gets logged, and it's
|
||||
unavailable anyway.
|
||||
- **Cursor lock** — `CursorLock.isSupported` is `!kIsWeb && macOS`. Off macOS
|
||||
`lock()` / `unlock()` do nothing and no deltas come back, so the scrub widget
|
||||
drives itself off Flutter's normal pointer events.
|
||||
|
||||
If you see a `MissingPluginException` for either channel on macOS, the runner
|
||||
hasnt been rebuilt since `garage_ui` was added — a hot restart doesnt register
|
||||
new plugins, a full `flutter run` / build does.
|
||||
|
||||
|
||||
## The pub bug with git + path deps
|
||||
|
||||
On some Dart versions pub loses the dependencies of a **git package that has
|
||||
a path dependency of its own**. That's exactly our shape: `garage_entitlements`
|
||||
and `garage_iap` are git deps (with `path:`) that themselves depend on
|
||||
`../garage_auth` by path. When it hits, the extra deps those packages declare —
|
||||
`pointycastle` is the one you'll notice — go missing from the resolved graph,
|
||||
and the build dies with an error mentioning `package_graph.json` (something
|
||||
like `dependencies for ... missing. Try running flutter pub get`, which doesnt
|
||||
help).
|
||||
|
||||
We've seen it on Dart 3.12.0; 3.12.2 is fine. Upgrading is the real fix. If you
|
||||
cant, declare the missing dep directly in your app so pub resolves it
|
||||
regardless:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
pointycastle: ^4.0.0
|
||||
```
|
||||
|
||||
Upstream: [dart-lang/pub#2447](https://github.com/dart-lang/pub/issues/2447)
|
||||
(git package depending on a path package) and
|
||||
[dart-lang/pub#4674](https://github.com/dart-lang/pub/issues/4674) (the
|
||||
`package_graph.json` error).
|
||||
@@ -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,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
# the SDK prints caught errors to console on purpose for debugging
|
||||
avoid_print: false
|
||||
@@ -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"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,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"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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")));
|
||||
});
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(":");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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!,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
@@ -0,0 +1,562 @@
|
||||
import "dart:math" as math;
|
||||
import "dart:ui" show Color, lerpDouble;
|
||||
import "package:flutter/painting.dart" show HSLColor;
|
||||
import "package:flutter/widgets.dart" show Brightness;
|
||||
|
||||
// The apps own colour scheme. Used to be a subclass of shadcns ColorScheme which
|
||||
// meant it kept getting flattened back to a plain base scheme every frame by the
|
||||
// theme lerp - now its just our own first class type, nothing above it.
|
||||
//
|
||||
// Holds the usual semantic slots (background/foreground/primary/...) plus the
|
||||
// app specific ones (chrome, panel borders, the control family) as equals.
|
||||
//
|
||||
// ── On the names ──────────────────────────────────────────────────────────
|
||||
// The control family used to be called input*. That was a lie measured
|
||||
// against its own call sites: of the eighteen reads of `inputBorder` exactly
|
||||
// one was a text field, and the rest were outline buttons, selects, date
|
||||
// inputs, checkboxes, radios, the colour swatch and toast. Anything with a
|
||||
// stroke around it took the "input" token because that was the only stroke
|
||||
// on offer. They're control* now, which is what they always were.
|
||||
//
|
||||
// `surfaceSunken` has the same history from the other end: it was
|
||||
// explorerRowEven, a zebra stripe, and every consumer bar one was using it as
|
||||
// "one step below the ground" for a whole pane. It's a rung on the ladder,
|
||||
// not a row colour, so it gets a rung's name.
|
||||
class ColourScheme {
|
||||
const ColourScheme({
|
||||
required this.brightness,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.card,
|
||||
required this.popover,
|
||||
required this.popoverBorder,
|
||||
required this.tooltipBackground,
|
||||
required this.tooltipBorder,
|
||||
required this.primary,
|
||||
required this.primaryHovered,
|
||||
required this.primaryForeground,
|
||||
required this.secondary,
|
||||
required this.secondaryHovered,
|
||||
required this.secondaryForeground,
|
||||
required this.muted,
|
||||
required this.mutedForeground,
|
||||
required this.destructive,
|
||||
required this.border,
|
||||
required this.divider,
|
||||
required this.surfaceSunken,
|
||||
required this.controlFill,
|
||||
required this.controlFillHovered,
|
||||
required this.controlFillFocused,
|
||||
required this.controlBorder,
|
||||
required this.switchTrackInactive,
|
||||
required this.ring,
|
||||
required this.chart1,
|
||||
required this.chart2,
|
||||
required this.chart3,
|
||||
required this.chart4,
|
||||
required this.chart5,
|
||||
required this.chrome,
|
||||
required this.panelBorder,
|
||||
required this.panelBorderHighlighted,
|
||||
required this.rowHovered,
|
||||
required this.rowText,
|
||||
required this.popoverItemHovered,
|
||||
required this.propertiesSectionBorder,
|
||||
});
|
||||
|
||||
/// Builds a complete scheme from the handful of colours an app actually
|
||||
/// chooses, deriving the rest.
|
||||
///
|
||||
/// ── How the derivation works ──────────────────────────────────────────
|
||||
///
|
||||
/// This used to be a table of HSL lightness offsets. HSL lightness is not
|
||||
/// perceptually uniform and, worse, an offset that runs off the end of the
|
||||
/// scale was *reflected*: it kept its size and lost only its direction. On
|
||||
/// a ground already near the floor that turned "recess this by twelve" into
|
||||
/// "raise it by twelve", and A&As carbon ended up with a canvas backdrop
|
||||
/// LIGHTER than the paper sat in front of it.
|
||||
///
|
||||
/// Measuring the ten hand authored schemes in CIE L* showed what they
|
||||
/// actually have in common, and its four rules rather than one table:
|
||||
///
|
||||
/// 1. Raised rungs are an absolute L* step off the ground. Zinc and carbon
|
||||
/// agree here to within 1-3 points despite sitting sixteen points apart,
|
||||
/// so this part was never the problem.
|
||||
///
|
||||
/// 2. Recessed rungs are the same idea until the ground runs out of room
|
||||
/// underneath, and then they all squeeze by the same factor. Zinc has
|
||||
/// 19.9 L* of basement and spends 12.6 of it on chrome. Carbon has 3.6
|
||||
/// and spends all of it. Squeezing keeps their ORDER, which is the thing
|
||||
/// that actually matters - reflecting destroyed it.
|
||||
///
|
||||
/// 3. A stroke is measured against the surface it outlines, not against the
|
||||
/// ground. Every scheme puts its popover border about ten points over
|
||||
/// its popover; none of them puts it ten points over the background.
|
||||
/// (This is the fix that landed for propertiesSectionBorder alone in
|
||||
/// 38a206f, generalised - it was never a one slot problem.)
|
||||
///
|
||||
/// 4. Text is a fraction of the background->foreground span, not an
|
||||
/// absolute shift. mutedForeground is 60.7% of the way in zinc and
|
||||
/// 60.7% in carbon, to one decimal, on spans of 71 and 92 points.
|
||||
///
|
||||
/// Anything you want to pin exactly is still an argument - every derived
|
||||
/// slot has an override. Hand authored schemes keep using the const
|
||||
/// constructor and are untouched.
|
||||
factory ColourScheme.derive({
|
||||
required Brightness brightness,
|
||||
required Color background,
|
||||
required Color foreground,
|
||||
required Color primary,
|
||||
Color? primaryForeground,
|
||||
Color? destructive,
|
||||
Color? ring,
|
||||
// --- escape hatches: pin any derived slot ---
|
||||
Color? chrome,
|
||||
Color? card,
|
||||
Color? popover,
|
||||
Color? muted,
|
||||
Color? tooltipBackground,
|
||||
Color? secondary,
|
||||
Color? border,
|
||||
Color? surfaceSunken,
|
||||
Color? controlFill,
|
||||
Color? controlFillHovered,
|
||||
Color? controlFillFocused,
|
||||
Color? controlBorder,
|
||||
Color? switchTrackInactive,
|
||||
Color? mutedForeground,
|
||||
Color? rowText,
|
||||
Color? chart1,
|
||||
Color? chart2,
|
||||
Color? chart3,
|
||||
Color? chart4,
|
||||
Color? chart5,
|
||||
}) {
|
||||
final groundL = _lstar(background);
|
||||
final span = _lstar(foreground) - groundL;
|
||||
|
||||
// How much of what the recessed rungs WANT this ground can actually give
|
||||
// them. chrome is the deepest slot the shared scheme has (the canvas
|
||||
// backdrop goes deeper, but thats an app side slot now), so it sets the
|
||||
// scale: if theres room for it nothing squeezes at all.
|
||||
final basement = brightness == Brightness.dark
|
||||
? groundL
|
||||
: math.max(groundL, _deepestSink);
|
||||
final squeeze = basement >= _deepestSink ? 1.0 : basement / _deepestSink;
|
||||
|
||||
Color raise(double points) => _atLstar(background, groundL + points);
|
||||
Color sink(double points) =>
|
||||
_atLstar(background, groundL - points * squeeze);
|
||||
|
||||
// a stroke sits N points off the surface it outlines. reflects if theres
|
||||
// no headroom that way - a border that cant get lighter than its own fill
|
||||
// gets darker by the same amount, which is what youd have picked anyway.
|
||||
Color edge(Color surface, double points) {
|
||||
final l = _lstar(surface);
|
||||
final wanted = l + points;
|
||||
return _atLstar(
|
||||
surface,
|
||||
(wanted > 100 || wanted < 0) ? l - points : wanted,
|
||||
);
|
||||
}
|
||||
|
||||
Color text(double fraction) =>
|
||||
_atLstar(foreground, groundL + span * fraction);
|
||||
|
||||
final resolvedChrome = chrome ?? sink(12.6);
|
||||
final resolvedCard = card ?? raise(5.9);
|
||||
final resolvedPopover = popover ?? sink(11.6);
|
||||
final resolvedMuted = muted ?? sink(9.1);
|
||||
final resolvedFill = controlFill ?? sink(9.1);
|
||||
final resolvedTooltip = tooltipBackground ?? sink(9.1);
|
||||
|
||||
return ColourScheme(
|
||||
brightness: brightness,
|
||||
background: background,
|
||||
foreground: foreground,
|
||||
|
||||
card: resolvedCard,
|
||||
|
||||
popover: resolvedPopover,
|
||||
popoverBorder: edge(resolvedPopover, 9.9),
|
||||
|
||||
tooltipBackground: resolvedTooltip,
|
||||
tooltipBorder: edge(resolvedTooltip, 9.1),
|
||||
|
||||
primary: primary,
|
||||
// +11 L*, CLAMPED rather than reflected. A stroke that cant get lighter
|
||||
// than its surface has to go the other way or it disappears, but a hover
|
||||
// has no such problem - it just wants to be brighter, and a near white
|
||||
// primary should hover to white, not turn round and dim to grey.
|
||||
primaryHovered: _atLstar(primary, math.min(100, _lstar(primary) + 11.0)),
|
||||
primaryForeground: primaryForeground ?? _contrastColor(primary),
|
||||
|
||||
secondary: secondary ?? raise(16.3),
|
||||
secondaryHovered: raise(23.9),
|
||||
// nine of the ten hand authored schemes put this exactly on foreground.
|
||||
secondaryForeground: foreground,
|
||||
|
||||
muted: resolvedMuted,
|
||||
mutedForeground: mutedForeground ?? text(0.607),
|
||||
|
||||
destructive:
|
||||
destructive ??
|
||||
(brightness == Brightness.dark
|
||||
? const Color(0xffa9575f)
|
||||
: const Color(0xffb3474f)),
|
||||
|
||||
// both reference schemes put the outermost line on the floor with
|
||||
// chrome - its the gutter between panels, not a stroke on a surface.
|
||||
border: border ?? resolvedChrome,
|
||||
// 4.1 points off the ground is a line you have to go looking for. The
|
||||
// shell has drawn its own rules with panelBorder (9.6) since forever
|
||||
// and nobody has ever called those loud, so a divider sits just under
|
||||
// that - visible, still subordinate to the edge of a panel.
|
||||
divider: edge(background, 9.0),
|
||||
|
||||
surfaceSunken: surfaceSunken ?? sink(3.8),
|
||||
|
||||
controlFill: resolvedFill,
|
||||
controlFillHovered: controlFillHovered ?? sink(6.5),
|
||||
controlFillFocused: controlFillFocused ?? sink(11.0),
|
||||
controlBorder: controlBorder ?? edge(resolvedFill, 11.8),
|
||||
|
||||
// the off state track of a Switch. was called `input`, which told you
|
||||
// nothing and collided with muted in every derived scheme.
|
||||
switchTrackInactive: switchTrackInactive ?? resolvedMuted,
|
||||
|
||||
ring: ring ?? _brightenForSelectionRing(primary),
|
||||
|
||||
chart1: chart1 ?? const Color(0xffff3352),
|
||||
chart2: chart2 ?? const Color(0xff8bdc00),
|
||||
chart3: chart3 ?? const Color(0xff2890ff),
|
||||
chart4: chart4 ?? const Color(0xffedba18),
|
||||
chart5: chart5 ?? const Color(0xffed5700),
|
||||
|
||||
chrome: resolvedChrome,
|
||||
|
||||
panelBorder: edge(background, 9.6),
|
||||
panelBorderHighlighted: raise(20.8),
|
||||
|
||||
rowHovered: raise(10.0),
|
||||
rowText: rowText ?? text(0.814),
|
||||
popoverItemHovered: raise(8.0),
|
||||
|
||||
// +6.2 over `card`, which is what a properties section is filled with -
|
||||
// NOT off the ground. Measured off the background it sat under a point
|
||||
// above its own fill and vanished into it.
|
||||
propertiesSectionBorder: edge(resolvedCard, 6.2),
|
||||
);
|
||||
}
|
||||
|
||||
final Brightness brightness;
|
||||
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
|
||||
/// Raised surface. Cards, and the fill behind a properties section.
|
||||
final Color card;
|
||||
|
||||
final Color popover;
|
||||
final Color popoverBorder;
|
||||
|
||||
// tooltips get their own pair rather than riding popover's - they sit on
|
||||
// top of everything and want more contrast than a panel-level surface.
|
||||
final Color tooltipBackground;
|
||||
final Color tooltipBorder;
|
||||
|
||||
final Color primary;
|
||||
final Color primaryHovered;
|
||||
final Color primaryForeground;
|
||||
final Color secondary;
|
||||
// secondary fill, hovered. used to be one hardcoded grey duplicated in
|
||||
// button.dart and select.dart, which meant the crimson/light schemes both
|
||||
// hovered to the same dark grey.
|
||||
final Color secondaryHovered;
|
||||
final Color secondaryForeground;
|
||||
final Color muted;
|
||||
final Color mutedForeground;
|
||||
final Color destructive;
|
||||
final Color border;
|
||||
final Color divider;
|
||||
|
||||
/// One step below the ground. Side panes, list backgrounds, anything
|
||||
/// recessed into the surface its sat on rather than lifted off it.
|
||||
final Color surfaceSunken;
|
||||
|
||||
// The control family: the fill and stroke every control shares - text
|
||||
// fields, selects, date inputs, outline and ghost buttons, checkboxes,
|
||||
// radios, the colour swatch. NOT text-field-only, whatever the old names
|
||||
// claimed.
|
||||
final Color controlFill;
|
||||
final Color controlFillHovered;
|
||||
final Color controlFillFocused;
|
||||
final Color controlBorder;
|
||||
|
||||
/// A Switch's track while its off. Its own slot because nothing else wants
|
||||
/// this colour and it used to squat on `input`.
|
||||
final Color switchTrackInactive;
|
||||
|
||||
final Color ring;
|
||||
final Color chart1;
|
||||
final Color chart2;
|
||||
final Color chart3;
|
||||
final Color chart4;
|
||||
final Color chart5;
|
||||
|
||||
// header/footer chrome, nudged off background so it reads as chrome.
|
||||
final Color chrome;
|
||||
|
||||
// borders of a content panel. The panel FILL is just `background` - all ten
|
||||
// hand authored schemes had them identical, so theres no slot for it.
|
||||
final Color panelBorder;
|
||||
final Color panelBorderHighlighted;
|
||||
|
||||
/// Hovered row, in a list or a menu. One slot: menuItemHovered and
|
||||
/// explorerRowHovered were the same colour in all ten schemes.
|
||||
final Color rowHovered;
|
||||
|
||||
/// Resting label/icon colour for a row. Was explorerRowText, and
|
||||
/// propertiesSectionLabel was the same colour in all ten schemes.
|
||||
final Color rowText;
|
||||
|
||||
// Interactive rows inside popovers. Kept separate from row hover while the
|
||||
// two interaction colours are being evaluated.
|
||||
final Color popoverItemHovered;
|
||||
|
||||
/// Outline of an object properties section. Measured off `card`, which is
|
||||
/// what fills one.
|
||||
final Color propertiesSectionBorder;
|
||||
|
||||
ColourScheme copyWith({
|
||||
Brightness? brightness,
|
||||
Color? background,
|
||||
Color? foreground,
|
||||
Color? card,
|
||||
Color? popover,
|
||||
Color? popoverBorder,
|
||||
Color? tooltipBackground,
|
||||
Color? tooltipBorder,
|
||||
Color? primary,
|
||||
Color? primaryHovered,
|
||||
Color? primaryForeground,
|
||||
Color? secondary,
|
||||
Color? secondaryHovered,
|
||||
Color? secondaryForeground,
|
||||
Color? muted,
|
||||
Color? mutedForeground,
|
||||
Color? destructive,
|
||||
Color? border,
|
||||
Color? divider,
|
||||
Color? surfaceSunken,
|
||||
Color? controlFill,
|
||||
Color? controlFillHovered,
|
||||
Color? controlFillFocused,
|
||||
Color? controlBorder,
|
||||
Color? switchTrackInactive,
|
||||
Color? ring,
|
||||
Color? chart1,
|
||||
Color? chart2,
|
||||
Color? chart3,
|
||||
Color? chart4,
|
||||
Color? chart5,
|
||||
Color? chrome,
|
||||
Color? panelBorder,
|
||||
Color? panelBorderHighlighted,
|
||||
Color? rowHovered,
|
||||
Color? rowText,
|
||||
Color? popoverItemHovered,
|
||||
Color? propertiesSectionBorder,
|
||||
}) {
|
||||
return ColourScheme(
|
||||
brightness: brightness ?? this.brightness,
|
||||
background: background ?? this.background,
|
||||
foreground: foreground ?? this.foreground,
|
||||
card: card ?? this.card,
|
||||
popover: popover ?? this.popover,
|
||||
popoverBorder: popoverBorder ?? this.popoverBorder,
|
||||
tooltipBackground: tooltipBackground ?? this.tooltipBackground,
|
||||
tooltipBorder: tooltipBorder ?? this.tooltipBorder,
|
||||
primary: primary ?? this.primary,
|
||||
primaryHovered: primaryHovered ?? this.primaryHovered,
|
||||
primaryForeground: primaryForeground ?? this.primaryForeground,
|
||||
secondary: secondary ?? this.secondary,
|
||||
secondaryHovered: secondaryHovered ?? this.secondaryHovered,
|
||||
secondaryForeground: secondaryForeground ?? this.secondaryForeground,
|
||||
muted: muted ?? this.muted,
|
||||
mutedForeground: mutedForeground ?? this.mutedForeground,
|
||||
destructive: destructive ?? this.destructive,
|
||||
border: border ?? this.border,
|
||||
divider: divider ?? this.divider,
|
||||
surfaceSunken: surfaceSunken ?? this.surfaceSunken,
|
||||
controlFill: controlFill ?? this.controlFill,
|
||||
controlFillHovered: controlFillHovered ?? this.controlFillHovered,
|
||||
controlFillFocused: controlFillFocused ?? this.controlFillFocused,
|
||||
controlBorder: controlBorder ?? this.controlBorder,
|
||||
switchTrackInactive: switchTrackInactive ?? this.switchTrackInactive,
|
||||
ring: ring ?? this.ring,
|
||||
chart1: chart1 ?? this.chart1,
|
||||
chart2: chart2 ?? this.chart2,
|
||||
chart3: chart3 ?? this.chart3,
|
||||
chart4: chart4 ?? this.chart4,
|
||||
chart5: chart5 ?? this.chart5,
|
||||
chrome: chrome ?? this.chrome,
|
||||
panelBorder: panelBorder ?? this.panelBorder,
|
||||
panelBorderHighlighted:
|
||||
panelBorderHighlighted ?? this.panelBorderHighlighted,
|
||||
rowHovered: rowHovered ?? this.rowHovered,
|
||||
rowText: rowText ?? this.rowText,
|
||||
popoverItemHovered: popoverItemHovered ?? this.popoverItemHovered,
|
||||
propertiesSectionBorder:
|
||||
propertiesSectionBorder ?? this.propertiesSectionBorder,
|
||||
);
|
||||
}
|
||||
|
||||
// Accent override - swaps primary/ring to the given accent colour, and picks a
|
||||
// readable foreground for it. Mirrors what shadcns recolor() did. Pass null
|
||||
// (the "none" accent) to leave the scheme untouched.
|
||||
ColourScheme withAccent(Color? accent) {
|
||||
if (accent == null) return this;
|
||||
return copyWith(
|
||||
primary: accent,
|
||||
primaryHovered: accent,
|
||||
primaryForeground: _contrastColor(accent),
|
||||
ring: accent,
|
||||
);
|
||||
}
|
||||
|
||||
static ColourScheme lerp(ColourScheme a, ColourScheme b, double t) {
|
||||
if (t <= 0) return a;
|
||||
if (t >= 1) return b;
|
||||
Color c(Color x, Color y) => Color.lerp(x, y, t)!;
|
||||
return ColourScheme(
|
||||
brightness: t < 0.5 ? a.brightness : b.brightness,
|
||||
background: c(a.background, b.background),
|
||||
foreground: c(a.foreground, b.foreground),
|
||||
card: c(a.card, b.card),
|
||||
popover: c(a.popover, b.popover),
|
||||
popoverBorder: c(a.popoverBorder, b.popoverBorder),
|
||||
tooltipBackground: c(a.tooltipBackground, b.tooltipBackground),
|
||||
tooltipBorder: c(a.tooltipBorder, b.tooltipBorder),
|
||||
primary: c(a.primary, b.primary),
|
||||
primaryHovered: c(a.primaryHovered, b.primaryHovered),
|
||||
primaryForeground: c(a.primaryForeground, b.primaryForeground),
|
||||
secondary: c(a.secondary, b.secondary),
|
||||
secondaryHovered: c(a.secondaryHovered, b.secondaryHovered),
|
||||
secondaryForeground: c(a.secondaryForeground, b.secondaryForeground),
|
||||
muted: c(a.muted, b.muted),
|
||||
mutedForeground: c(a.mutedForeground, b.mutedForeground),
|
||||
destructive: c(a.destructive, b.destructive),
|
||||
border: c(a.border, b.border),
|
||||
divider: c(a.divider, b.divider),
|
||||
surfaceSunken: c(a.surfaceSunken, b.surfaceSunken),
|
||||
controlFill: c(a.controlFill, b.controlFill),
|
||||
controlFillHovered: c(a.controlFillHovered, b.controlFillHovered),
|
||||
controlFillFocused: c(a.controlFillFocused, b.controlFillFocused),
|
||||
controlBorder: c(a.controlBorder, b.controlBorder),
|
||||
switchTrackInactive: c(a.switchTrackInactive, b.switchTrackInactive),
|
||||
ring: c(a.ring, b.ring),
|
||||
chart1: c(a.chart1, b.chart1),
|
||||
chart2: c(a.chart2, b.chart2),
|
||||
chart3: c(a.chart3, b.chart3),
|
||||
chart4: c(a.chart4, b.chart4),
|
||||
chart5: c(a.chart5, b.chart5),
|
||||
chrome: c(a.chrome, b.chrome),
|
||||
panelBorder: c(a.panelBorder, b.panelBorder),
|
||||
panelBorderHighlighted: c(
|
||||
a.panelBorderHighlighted,
|
||||
b.panelBorderHighlighted,
|
||||
),
|
||||
rowHovered: c(a.rowHovered, b.rowHovered),
|
||||
rowText: c(a.rowText, b.rowText),
|
||||
popoverItemHovered: c(a.popoverItemHovered, b.popoverItemHovered),
|
||||
propertiesSectionBorder: c(
|
||||
a.propertiesSectionBorder,
|
||||
b.propertiesSectionBorder,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [base]'s lightness in CIE L*, 0 (black) to 100 (white).
|
||||
double lstarOf(Color base) => _lstar(base);
|
||||
|
||||
/// [base] moved [points] in CIE L*, keeping its hue and saturation.
|
||||
///
|
||||
/// Reflects rather than clamps when theres no room that way: a colour that
|
||||
/// cant get [points] lighter gets [points] darker instead, keeping the size of
|
||||
/// the step and losing only its direction. A clamp doesnt shorten a step, it
|
||||
/// deletes it - two slots asking for +6 and +11 against a near-white ground
|
||||
/// both land on white and a distinction that exists in every other scheme is
|
||||
/// gone.
|
||||
///
|
||||
/// This is the same step [ColourScheme.derive] is built out of, exposed so an
|
||||
/// app deriving extra slots of its own (Arcs & Angles' canvas colours) walks
|
||||
/// the identical ladder rather than reinventing a near-miss of it.
|
||||
Color shiftLstar(Color base, double points) {
|
||||
final l = _lstar(base);
|
||||
final wanted = l + points;
|
||||
return _atLstar(base, (wanted > 100 || wanted < 0) ? l - points : wanted);
|
||||
}
|
||||
|
||||
// chrome is the deepest recessed slot the shared scheme has, so its want sets
|
||||
// the squeeze factor for every other one.
|
||||
const double _deepestSink = 12.6;
|
||||
|
||||
// ── CIE L* ───────────────────────────────────────────────────────────────
|
||||
// The offsets above are all in L*, which is perceptually uniform - five
|
||||
// points looks like the same step whether youre near black or near white.
|
||||
// HSL lightness, which this used to use, very much does not.
|
||||
|
||||
double _channel(double v) =>
|
||||
v <= 0.04045 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
||||
|
||||
double _lstar(Color c) {
|
||||
final y =
|
||||
0.2126 * _channel(c.r) + 0.7152 * _channel(c.g) + 0.0722 * _channel(c.b);
|
||||
return y > 0.008856 ? 116 * math.pow(y, 1 / 3).toDouble() - 16 : 903.3 * y;
|
||||
}
|
||||
|
||||
// [base]'s hue and saturation at the given L*.
|
||||
//
|
||||
// Theres no closed form for this that keeps HSL saturation fixed, so it
|
||||
// bisects on HSL lightness instead - which is fine, its monotonic in
|
||||
// luminance and this runs once per scheme at startup, not per frame.
|
||||
Color _atLstar(Color base, double target) {
|
||||
final hsl = HSLColor.fromColor(base);
|
||||
final want = target.clamp(0.0, 100.0);
|
||||
|
||||
var lo = 0.0;
|
||||
var hi = 1.0;
|
||||
for (var i = 0; i < 18; i++) {
|
||||
final mid = (lo + hi) / 2;
|
||||
if (_lstar(hsl.withLightness(mid).toColor()) < want) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return hsl.withLightness((lo + hi) / 2).toColor();
|
||||
}
|
||||
|
||||
// flips lightness to get a readable foreground on a given colour. same idea as
|
||||
// shadcns getContrastColor (full luminance contrast).
|
||||
Color _contrastColor(Color on) {
|
||||
final hsl = HSLColor.fromColor(on);
|
||||
final l = hsl.lightness;
|
||||
final target = l >= 0.5 ? 0.0 : 1.0;
|
||||
// nudge toward the target rather than pure black/white so it doesnt look harsh
|
||||
final mixed = lerpDouble(l, target, 1.0)!;
|
||||
return hsl.withLightness(mixed.clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
|
||||
// a focus ring wants to read punchier than a resting "primary" swatch -
|
||||
// lighter and more saturated, so it pops against whatevers behind it instead
|
||||
// of just matching a button colour.
|
||||
Color _brightenForSelectionRing(Color colour) {
|
||||
final hsl = HSLColor.fromColor(colour);
|
||||
return hsl
|
||||
.withSaturation((hsl.saturation + 0.16).clamp(0.0, 1.0))
|
||||
.withLightness((hsl.lightness + 0.13).clamp(0.0, 0.78))
|
||||
.toColor();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
|
||||
import "package:garage_ui/theme/theme_data.dart";
|
||||
|
||||
export "package:garage_ui/theme/theme_data.dart";
|
||||
export "package:garage_ui/theme/colour_scheme.dart";
|
||||
export "package:garage_ui/theme/typography.dart";
|
||||
|
||||
// The apps theme. Hands the theme data down via a plain inherited scope (no
|
||||
// animated wrapper, no per frame ColorScheme.lerp flattening the whole thing -
|
||||
// this is the bit that used to feel second class), AND establishes a sane
|
||||
// DefaultTextStyle + IconTheme so bare Text/Icon dont fall back to flutter's
|
||||
// yellow-underline "unstyled" debug look (MaterialApp only styles text inside a
|
||||
// Material, and our panels arent Material).
|
||||
class GarageTheme extends StatelessWidget {
|
||||
const GarageTheme({super.key, required this.data, required this.child});
|
||||
|
||||
final ThemeData data;
|
||||
final Widget child;
|
||||
|
||||
static ThemeData of(BuildContext context) {
|
||||
final t = maybeOf(context);
|
||||
assert(
|
||||
t != null,
|
||||
"No GarageTheme found in context. Wrap the app in an GarageTheme.",
|
||||
);
|
||||
return t!;
|
||||
}
|
||||
|
||||
static ThemeData? maybeOf(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<_GarageThemeScope>()
|
||||
?.data;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = data.colorScheme;
|
||||
return _GarageThemeScope(
|
||||
data: data,
|
||||
child: DefaultTextStyle(
|
||||
// the one app-wide UI font, declared right here at the top of the tree.
|
||||
// everything below that doesnt override fontFamily inherits Geist.
|
||||
// (canvas-painted text - station labels, watermark - sits outside the
|
||||
// widget tree so it isnt touched by this.)
|
||||
style: GoogleFonts.geist(
|
||||
color: cs.foreground,
|
||||
// off the density, not a literal times scaling. this was the third
|
||||
// independent source of text size in the package - controls read
|
||||
// density.fontSize, the .xSmall()/.small()/.large() ladder now reads
|
||||
// it too, and this floated free at 11 * scaling. Retune fontSize and
|
||||
// page copy no longer stays behind while the controls move.
|
||||
//
|
||||
// fontSize, NOT textXs. The ladder above the control font is a set
|
||||
// of MULTIPLIERS (xs 1.1x, sm 1.4x, lg 1.8x), so what textXs means
|
||||
// depends on the tier: at A&A's 10 its 11, a hair over the control
|
||||
// font and harmless, and at the product tier's 16 its 18 - body copy
|
||||
// rendering BIGGER than the text inside the buttons and fields next
|
||||
// to it. Thats backwards, and its what made the product surfaces
|
||||
// read as oversized even with their control geometry correct.
|
||||
//
|
||||
// Body copy is the control font. A sentence and the text in the box
|
||||
// under it are the same size, at every tier, by construction.
|
||||
fontSize: data.density.fontSize,
|
||||
// the important bit - kills the yellow underline.
|
||||
decoration: TextDecoration.none,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
color: cs.foreground,
|
||||
size: data.iconTheme.medium.size,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GarageThemeScope extends InheritedWidget {
|
||||
const _GarageThemeScope({required this.data, required super.child});
|
||||
|
||||
final ThemeData data;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_GarageThemeScope oldWidget) =>
|
||||
oldWidget.data != data;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import "dart:ui" show Color;
|
||||
|
||||
import "package:flutter/widgets.dart" show Brightness;
|
||||
|
||||
import "package:garage_ui/theme/colour_scheme.dart";
|
||||
|
||||
/// Ready-made schemes, so a new app can look like a Garage app on line one.
|
||||
///
|
||||
/// Standing one up used to mean authoring 54 colours by hand before writing
|
||||
/// any app code. These are the neutral pair, built through
|
||||
/// [ColourScheme.derive] from four colours each — which is also the worked
|
||||
/// example of how to make your own.
|
||||
///
|
||||
/// ```dart
|
||||
/// GarageApp.router(
|
||||
/// theme: ThemeData(colorScheme: GarageSchemes.dark),
|
||||
/// ...
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// Want your own hue? Change [ColourScheme.derive]'s `primary` and leave the
|
||||
/// rest. Want one slot exact? Every derived slot has an override.
|
||||
abstract final class GarageSchemes {
|
||||
/// The neutral dark scheme. Same core Arcs & Angles' zinc is built on.
|
||||
static final ColourScheme dark = ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff303030),
|
||||
foreground: const Color(0xffe6e6e6),
|
||||
primary: const Color(0xff4772b3),
|
||||
);
|
||||
|
||||
/// The neutral light scheme — the same blue on a faintly cool near-white.
|
||||
static final ColourScheme light = ColourScheme.derive(
|
||||
brightness: Brightness.light,
|
||||
background: const Color(0xfff2f2f4),
|
||||
foreground: const Color(0xff1a1a1c),
|
||||
primary: const Color(0xff3a63a1),
|
||||
);
|
||||
|
||||
/// Carbon. True black, no chroma at all - built for oled panels, and the
|
||||
/// scheme the Garage apps actually run.
|
||||
///
|
||||
/// It lives here rather than in each app because it didnt: the hub and Arcs
|
||||
/// & Angles each kept their own copy and they drifted apart in twenty five
|
||||
/// of fifty two slots before anyone noticed. One definition cant.
|
||||
///
|
||||
/// Carbon has 3.6 L* of room under its ground, against zinc's 19.9, so every
|
||||
/// recessed rung squeezes into what there is - which is why chrome lands on
|
||||
/// the floor without being told to. The pins are where carbon wants
|
||||
/// something other than what the ladder gives it.
|
||||
static final ColourScheme carbon = ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff0d0d0d),
|
||||
foreground: const Color(0xfff2f2f2),
|
||||
|
||||
// no accent hue anywhere - primary is just a near white fill
|
||||
primary: const Color(0xffe8e8e8),
|
||||
primaryForeground: const Color(0xff000000),
|
||||
ring: const Color(0xffffffff),
|
||||
destructive: const Color(0xffc43333),
|
||||
|
||||
// the ladder puts these a shade off the floor. carbon wants the floor -
|
||||
// the gutter between panels is the whole look.
|
||||
popover: const Color(0xff000000),
|
||||
controlFillFocused: const Color(0xff000000),
|
||||
|
||||
// above the ground rather than below it, which is what carbon has always
|
||||
// done with muted. theres barely any "below" left to use.
|
||||
muted: const Color(0xff0f0f0f),
|
||||
controlFill: const Color(0xff080808),
|
||||
tooltipBackground: const Color(0xff080808),
|
||||
|
||||
// NOT the ladder's +11.8 over the fill. Carbon cant recess a field far
|
||||
// enough to read as recessed, so the stroke does all the separating and
|
||||
// has to be the strongest thing on the control, not the weakest.
|
||||
controlBorder: const Color(0xff2a2a2a),
|
||||
|
||||
chart1: const Color(0xfff45b69),
|
||||
chart2: const Color(0xff86d957),
|
||||
chart3: const Color(0xff62a7ff),
|
||||
chart4: const Color(0xffffc857),
|
||||
chart5: const Color(0xffff8c42),
|
||||
);
|
||||
|
||||
/// Whichever of the pair matches [brightness].
|
||||
static ColourScheme of(Brightness brightness) =>
|
||||
brightness == Brightness.dark ? dark : light;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import "dart:ui" show Color, ImageFilter;
|
||||
|
||||
import "package:flutter/painting.dart" show HSLColor;
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
import "package:garage_ui/theme/garage_theme.dart";
|
||||
|
||||
// small pile of helpers the GarageUI widgets used to pull off the shadcn barrel.
|
||||
// theyre generic, nothing shadcn specific, so we just keep our own copies.
|
||||
|
||||
// shadcns Color helpers that the widgets lean on.
|
||||
extension ColorExtension on Color {
|
||||
// scale the alpha channel by [factor] (0..1). shadcns scaleAlpha.
|
||||
Color scaleAlpha(double factor) {
|
||||
return withValues(alpha: (a * factor).clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
// a readable foreground for this colour - flips lightness. shadcns getContrastColor.
|
||||
Color getContrastColor([double luminanceContrast = 1]) {
|
||||
final hsl = HSLColor.fromColor(this);
|
||||
final l = hsl.lightness;
|
||||
final target = l >= 0.5
|
||||
? l - (l * luminanceContrast)
|
||||
: l + ((1 - l) * luminanceContrast);
|
||||
return hsl.withLightness(target.clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
}
|
||||
|
||||
// default anim duration shadcn used all over (hover/colour transitions etc).
|
||||
const Duration kDefaultDuration = Duration(milliseconds: 150);
|
||||
|
||||
// widget value wins, then theme value, then the hard default. shadcns styleValue.
|
||||
T styleValue<T>({T? widgetValue, T? themeValue, required T defaultValue}) {
|
||||
return widgetValue ?? themeValue ?? defaultValue;
|
||||
}
|
||||
|
||||
// we dropped shadcns density-insets system, so this is now just a passthrough -
|
||||
// the GarageUI widgets only ever hand it plain EdgeInsets anyway.
|
||||
EdgeInsetsGeometry resolveEdgeInsets(
|
||||
EdgeInsetsGeometry padding,
|
||||
double basePadding,
|
||||
) {
|
||||
return padding;
|
||||
}
|
||||
|
||||
// shrinks a border radius by the border width so an inner surface tucks neatly
|
||||
// inside its border. shadcns subtractByBorder.
|
||||
BorderRadius subtractByBorder(BorderRadius radius, double borderWidth) {
|
||||
Radius sub(Radius r) => Radius.elliptical(
|
||||
(r.x - borderWidth).clamp(0.0, double.infinity),
|
||||
(r.y - borderWidth).clamp(0.0, double.infinity),
|
||||
);
|
||||
return BorderRadius.only(
|
||||
topLeft: sub(radius.topLeft),
|
||||
topRight: sub(radius.topRight),
|
||||
bottomLeft: sub(radius.bottomLeft),
|
||||
bottomRight: sub(radius.bottomRight),
|
||||
);
|
||||
}
|
||||
|
||||
// used to be a density-aware Padding in shadcn. our padding is already resolved
|
||||
// by the time it gets here, so this is just a Padding.
|
||||
class DensityContainerPadding extends StatelessWidget {
|
||||
const DensityContainerPadding({
|
||||
super.key,
|
||||
required this.padding,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final EdgeInsetsGeometry padding;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(padding: padding, child: child);
|
||||
}
|
||||
|
||||
// shadcn used this to tell whether a surface was being rendered inside a sheet
|
||||
// so it could drop its own rounding/border. the app doesnt use sheets, so its
|
||||
// always false.
|
||||
class SheetOverlayHandler {
|
||||
const SheetOverlayHandler._();
|
||||
static bool isSheetOverlay(BuildContext context) => false;
|
||||
}
|
||||
|
||||
// draws a focus ring around a child when [focused]. shadcns FocusOutline, cut
|
||||
// down to what the text field needs.
|
||||
class FocusOutline extends StatelessWidget {
|
||||
const FocusOutline({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.focused,
|
||||
this.borderRadius,
|
||||
this.color,
|
||||
this.width = 1.0,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool focused;
|
||||
final BorderRadiusGeometry? borderRadius;
|
||||
final Color? color;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!focused) return child;
|
||||
final ring = color ?? GarageTheme.of(context).colorScheme.ring;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
// draw the ring OUTSIDE the box so it doesnt inset the child and
|
||||
// change the field size when focus comes and goes.
|
||||
border: Border.all(
|
||||
color: ring,
|
||||
width: width,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// blurs whatever is behind the child (glassmorphism on popovers/cards). ported
|
||||
// straight from shadcns outlined_container.dart.
|
||||
class SurfaceBlur extends StatefulWidget {
|
||||
const SurfaceBlur({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.surfaceBlur,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final double? surfaceBlur;
|
||||
final BorderRadiusGeometry? borderRadius;
|
||||
|
||||
@override
|
||||
State<SurfaceBlur> createState() => _SurfaceBlurState();
|
||||
}
|
||||
|
||||
class _SurfaceBlurState extends State<SurfaceBlur> {
|
||||
final GlobalKey _mainContainerKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.surfaceBlur == null || widget.surfaceBlur! <= 0) {
|
||||
return KeyedSubtree(key: _mainContainerKey, child: widget.child);
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: widget.borderRadius ?? BorderRadius.zero,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: widget.surfaceBlur!,
|
||||
sigmaY: widget.surfaceBlur!,
|
||||
),
|
||||
// needs a child or it wont actually blur anything
|
||||
child: const SizedBox(),
|
||||
),
|
||||
),
|
||||
),
|
||||
KeyedSubtree(key: _mainContainerKey, child: widget.child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import "package:flutter/foundation.dart"
|
||||
show TargetPlatform, defaultTargetPlatform;
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
import "package:garage_ui/theme/colour_scheme.dart";
|
||||
import "package:garage_ui/theme/typography.dart";
|
||||
|
||||
/// The two control tiers. One type for both levels: hand it to [ThemeData] to
|
||||
/// set the app-wide density, or to any control to override that control.
|
||||
///
|
||||
/// Replaces the pair this used to be - a `DensityMode` enum on the theme and a
|
||||
/// separate `ControlDensity` class on widgets - which modelled the same two
|
||||
/// values twice, in two shapes, and named the widget-level one after a single
|
||||
/// widget despite Select, Menubar, MenuButton, MenuPopup, TextField and
|
||||
/// TabView all taking it.
|
||||
enum ControlDensity {
|
||||
compact,
|
||||
normal;
|
||||
|
||||
bool get isCompact => this == ControlDensity.compact;
|
||||
|
||||
/// The canonical token set for this tier, ignoring any theme tuning.
|
||||
Density get canonical =>
|
||||
isCompact ? const Density.compact() : const Density.normal();
|
||||
|
||||
/// The tier the theme is currently set to.
|
||||
static ControlDensity of(ThemeData theme) => theme.density.control;
|
||||
|
||||
/// The token set a control on this tier should read.
|
||||
///
|
||||
/// When the tier matches the theme's, the theme's own [Density] is used, so
|
||||
/// a tuned one survives. When a control asks for the other tier, it falls
|
||||
/// back to that tier's canonical tokens.
|
||||
Density tokens(ThemeData theme) =>
|
||||
this == theme.density.control ? theme.density : canonical;
|
||||
|
||||
EdgeInsets resolve(ThemeData theme) => tokens(theme).buttonPadding;
|
||||
|
||||
/// Padding for an icon-only control, collapsed to its smallest side so the
|
||||
/// control comes out square rather than inheriting the asymmetric
|
||||
/// horizontal padding.
|
||||
EdgeInsets resolveIcon(ThemeData theme) => _squarePadding(resolve(theme));
|
||||
}
|
||||
|
||||
EdgeInsets _squarePadding(EdgeInsets p) {
|
||||
final side = p.vertical < p.horizontal ? p.vertical / 2 : p.horizontal / 2;
|
||||
return EdgeInsets.all(side);
|
||||
}
|
||||
|
||||
/// The app-wide UI density.
|
||||
///
|
||||
/// ONE RULE: the fields on this class are the only hand-set numbers in the
|
||||
/// control system. Everything else - line box, icon size, every vertical
|
||||
/// padding, every control height - is a getter derived from them. If you find
|
||||
/// yourself typing a pixel height anywhere else, it belongs here instead.
|
||||
///
|
||||
/// The heights are the configurable part. You say how tall a control should be
|
||||
/// and the padding falls out of it:
|
||||
///
|
||||
/// lineBox = fontSize * lineHeight
|
||||
/// controlPaddingY = (controlHeight - lineBox) / 2
|
||||
///
|
||||
/// NOT the other way round. Tuning padding until a height came out right is
|
||||
/// what produced four different control heights, a `Transform.translate` in two
|
||||
/// widgets, and 23/27 re-typed as literals in the explorer.
|
||||
///
|
||||
/// `lineBox` needs no font metrics: when a TextStyle carries an explicit
|
||||
/// `height`, Flutter sizes the line box to exactly `fontSize * height` and does
|
||||
/// not consult the font. That is what makes this whole chain deterministic and
|
||||
/// testable - a style with a null `height` measures 1.0x in the test host and
|
||||
/// ~1.25x in the app, which is how a ~26px TextField once passed a suite of
|
||||
/// tests that all asserted 23.
|
||||
class Density {
|
||||
const Density({
|
||||
this.control = ControlDensity.compact,
|
||||
|
||||
// ---- type ----
|
||||
this.fontSize = 10.0,
|
||||
this.lineHeight = 1.1,
|
||||
|
||||
// ---- height targets (configure these; paddings derive from them) ----
|
||||
this.controlHeight = 23.0,
|
||||
this.menuRowHeight = 20.0,
|
||||
this.popupRowHeight = 20.0,
|
||||
this.popupMaxRows = 12,
|
||||
this.explorerRowHeight = 22.0,
|
||||
|
||||
// ---- horizontal + misc primitives ----
|
||||
this.buttonPaddingX = 10.0,
|
||||
this.controlGap = 4.0,
|
||||
this.controlBorderWidth = 1.0,
|
||||
this.explorerRowBasePadding = 4.0,
|
||||
this.explorerRowEndPadding = 10.0,
|
||||
this.listRowIndent = 6.0,
|
||||
|
||||
// ---- container-level spacing (panels, popovers, dialogs) ----
|
||||
this.containerGap = 8.0,
|
||||
this.containerPadding = 16.0,
|
||||
|
||||
// ---- optical ----
|
||||
this.controlTextOffset = 0.0,
|
||||
});
|
||||
|
||||
/// The compact tier, said out loud. Same as the unnamed constructor's
|
||||
/// defaults - which is exactly why it exists, because `Density()` silently
|
||||
/// meaning "compact" was a trap.
|
||||
const Density.compact() : this();
|
||||
|
||||
const Density.normal()
|
||||
: control = ControlDensity.normal,
|
||||
fontSize = 10.0,
|
||||
lineHeight = 1.1,
|
||||
controlHeight = 27.0,
|
||||
menuRowHeight = 26.0,
|
||||
popupRowHeight = 26.0,
|
||||
popupMaxRows = 12,
|
||||
explorerRowHeight = 30.0,
|
||||
buttonPaddingX = 12.0,
|
||||
controlGap = 5.0,
|
||||
controlBorderWidth = 1.0,
|
||||
explorerRowBasePadding = 8.0,
|
||||
explorerRowEndPadding = 14.0,
|
||||
listRowIndent = 12.0,
|
||||
containerGap = 10.0,
|
||||
containerPadding = 20.0,
|
||||
controlTextOffset = 0.0;
|
||||
|
||||
/// The product tier - for the Garage web apps rather than for A&A.
|
||||
///
|
||||
/// compact and normal are both answers to "a properties inspector has to sit
|
||||
/// beside a viewport without eating it", which is why they share a 10px font
|
||||
/// and differ only in control geometry. An app with no canvas inherits that
|
||||
/// economy for nothing, so this tier scales the TYPE as well: 12px control
|
||||
/// font, 33px controls, and a gap ladder off a base of 5.
|
||||
///
|
||||
/// `control` stays [ControlDensity.normal] deliberately. That enum is the
|
||||
/// two-way geometry switch widgets branch on (`isCompact`), not a name for
|
||||
/// the tier, and A&A switches over it exhaustively in its settings UI.
|
||||
///
|
||||
/// 12 * 1.25 = 15 line box, (33 - 15) / 2 = 9 padding. both whole, so the
|
||||
/// control geometry stays exact - see [lineBox].
|
||||
const Density.product()
|
||||
: control = ControlDensity.normal,
|
||||
fontSize = 12.0,
|
||||
lineHeight = 1.25,
|
||||
controlHeight = 33.0,
|
||||
menuRowHeight = 33.0,
|
||||
popupRowHeight = 33.0,
|
||||
popupMaxRows = 10,
|
||||
explorerRowHeight = 29.0,
|
||||
buttonPaddingX = 10.0,
|
||||
controlGap = 5.0,
|
||||
controlBorderWidth = 1.0,
|
||||
explorerRowBasePadding = 8.0,
|
||||
explorerRowEndPadding = 12.0,
|
||||
listRowIndent = 10.0,
|
||||
containerGap = 10.0,
|
||||
containerPadding = 20.0,
|
||||
controlTextOffset = 0.0;
|
||||
|
||||
/// Which tier this token set represents.
|
||||
final ControlDensity control;
|
||||
|
||||
// ---- type ----
|
||||
|
||||
/// Control font size. The app is a fixed compact desktop tool, so these are
|
||||
/// final pixel values and are deliberately NOT multiplied by `scaling`.
|
||||
final double fontSize;
|
||||
|
||||
/// Line box as a multiple of [fontSize]. Pinning this is what makes control
|
||||
/// geometry font-independent - see the class doc.
|
||||
final double lineHeight;
|
||||
|
||||
// ---- height targets ----
|
||||
|
||||
/// Button / text field / select trigger / icon button.
|
||||
final double controlHeight;
|
||||
|
||||
/// Menu rows (MenuButton and friends).
|
||||
///
|
||||
/// Happens to equal [popupRowHeight] in both densities today - menus and
|
||||
/// select popups are the same kind of surface. Kept as two tokens anyway so
|
||||
/// one can move without dragging the other along.
|
||||
final double menuRowHeight;
|
||||
|
||||
/// Rows inside a select popup.
|
||||
final double popupRowHeight;
|
||||
|
||||
/// How many rows a select popup shows before it starts scrolling. A count,
|
||||
/// not a pixel value - the height falls out of it via [popupMaxHeight], so a
|
||||
/// denser popup gets shorter rather than showing more of them.
|
||||
final int popupMaxRows;
|
||||
|
||||
/// Explorer tree rows.
|
||||
final double explorerRowHeight;
|
||||
|
||||
// ---- horizontal + misc ----
|
||||
|
||||
final double buttonPaddingX;
|
||||
|
||||
/// Gap between a control's leading/trailing icon and its label. Sits INSIDE
|
||||
/// the control, so it is tighter than the outer padding on purpose.
|
||||
final double controlGap;
|
||||
|
||||
final double controlBorderWidth;
|
||||
|
||||
/// Leading indent applied per depth level in the explorer tree.
|
||||
final double explorerRowBasePadding;
|
||||
|
||||
/// Trailing inset on an explorer row, so the hide toggle isnt sat right
|
||||
/// under the scrollbar thumb.
|
||||
final double explorerRowEndPadding;
|
||||
|
||||
/// Leading inset on a flat list row, before its icon. Distinct from
|
||||
/// [explorerRowBasePadding], which is a per-depth indent in a tree.
|
||||
///
|
||||
/// Authored from the values the Lines slots list was already branching on by
|
||||
/// hand. It and the explorer disagree about this inset (6/12 vs 4/8) and
|
||||
/// always have - reconciling them is a visual decision, not a refactor.
|
||||
final double listRowIndent;
|
||||
|
||||
// ---- container-level ----
|
||||
|
||||
/// Spacing between elements in a panel / popover / dialog. This is layout
|
||||
/// spacing, NOT control-internal spacing - reach for [controlGap] inside a
|
||||
/// control. (Replaces the old shadcn-inherited `baseGap`.)
|
||||
final double containerGap;
|
||||
|
||||
/// Padding inside a panel / popover / dialog. (Replaces `baseContentPadding`
|
||||
/// and `baseContainerPadding`, which always held the same value.)
|
||||
final double containerPadding;
|
||||
|
||||
// ---- optical ----
|
||||
|
||||
/// Downward nudge applied to control content so it sits on its optical
|
||||
/// centre rather than its geometric one. Negative moves it up.
|
||||
///
|
||||
/// This is the one value the maths cannot settle on its own - the line box is
|
||||
/// exact, but where the ink sits inside it depends on the font's
|
||||
/// ascent/descent split. So it is a judgement made by eye, once, here. It
|
||||
/// used to be `Offset(0, 1)` hardcoded in Button and `Offset(0, 2)` in
|
||||
/// Select, which is two judgements that disagreed.
|
||||
///
|
||||
/// Currently 0 - i.e. the geometric centre is what looks right in Geist at
|
||||
/// this size. Keep the token even so: it is the knob, and a zero here costs
|
||||
/// nothing because the controls skip the transform entirely when it is 0.
|
||||
final double controlTextOffset;
|
||||
|
||||
// =========================================================================
|
||||
// derived - do not hand-set any of these, and do not re-derive them at a
|
||||
// call site
|
||||
// =========================================================================
|
||||
|
||||
/// Height of one line of control text: `fontSize * lineHeight`, rounded to a
|
||||
/// whole pixel.
|
||||
///
|
||||
/// Measured, not assumed. Against Georgia and Andale Mono (both natural ratio
|
||||
/// 1.10) and the test host's fallback (1.00): when the product is a whole
|
||||
/// number the engine lays the line box out at exactly that in all three, so
|
||||
/// the font's own ascent/descent genuinely do not participate. That is what
|
||||
/// makes control geometry font-independent and testable.
|
||||
///
|
||||
/// When the product is fractional the engine rounds it, and at a .5 tie the
|
||||
/// direction is FONT-DEPENDENT (13.5 came out 14 in Andale, 13 in Georgia).
|
||||
/// So keep `fontSize * lineHeight` on a whole number - 10 * 1.1 = 11 does -
|
||||
/// and every height below is exact. The rounding here keeps the derivation
|
||||
/// honest for other configs rather than quietly missing the target by a
|
||||
/// fraction of a pixel.
|
||||
double get lineBox => (fontSize * lineHeight).roundToDouble();
|
||||
|
||||
/// Control icons, sized against the TEXT rather than the line box.
|
||||
///
|
||||
/// This used to be `lineBox` - 15px at product - on the reasoning that an
|
||||
/// icon-only control and a text control then come out the same height for
|
||||
/// free. That holds, but it isnt what the eye measures: lucide glyphs fill
|
||||
/// their box nearly edge to edge while a 12px font has a cap height around
|
||||
/// 8.5px, so a line-box icon reads about 70% taller than the letters beside
|
||||
/// it and every button with an icon in it looked slightly wrong.
|
||||
///
|
||||
/// Level with the font. 1.1x was the first attempt at the optical match and
|
||||
/// still read a shade heavy next to the label beside it. Control HEIGHT is
|
||||
/// unaffected either way: that comes from controlHeight, not from whats
|
||||
/// inside.
|
||||
double get iconSize => fontSize;
|
||||
|
||||
double get controlPaddingY => (controlHeight - lineBox) / 2;
|
||||
double get menuRowPaddingY => (menuRowHeight - lineBox) / 2;
|
||||
double get popupRowPaddingY => (popupRowHeight - lineBox) / 2;
|
||||
double get explorerRowPaddingY => (explorerRowHeight - lineBox) / 2;
|
||||
|
||||
/// Height of a chrome bar - an editor's header or footer.
|
||||
///
|
||||
/// One [controlGap] above the control and one below, which is [gapMd] all
|
||||
/// told. A bar sized any tighter than that isnt giving its contents a
|
||||
/// margin so much as a haircut: the old hardcoded 30 left 1.5px over a
|
||||
/// normal-tier control and would have been SHORTER than a product-tier one,
|
||||
/// so a button in the header didnt fit the header.
|
||||
///
|
||||
/// Matching the vertical margin to the horizontal gap is the whole point -
|
||||
/// a row of buttons then sits in an even field instead of one thats
|
||||
/// generous side to side and tight top to bottom.
|
||||
///
|
||||
/// compact 23 + 8 = 31, normal 27 + 10 = 37, product 33 + 10 = 43
|
||||
double get chromeBarHeight => controlHeight + gapMd;
|
||||
|
||||
/// Cap on a select popup's list. Counts rows only - the list's own padding
|
||||
/// (and a search field, when there is one) sits on top, so the last row
|
||||
/// clips slightly rather than landing flush. Thats deliberate: a half row
|
||||
/// showing is the cheapest "theres more below" hint there is.
|
||||
///
|
||||
/// Was `kDefaultSelectMaxHeight = 240.0` in select.dart, hand-typed and then
|
||||
/// multiplied by `scaling`, so it never moved with density at all.
|
||||
double get popupMaxHeight => popupRowHeight * popupMaxRows;
|
||||
|
||||
/// Padding for a borderless control (ghost/primary button, etc).
|
||||
EdgeInsets get buttonPadding => EdgeInsets.symmetric(
|
||||
horizontal: buttonPaddingX,
|
||||
vertical: controlPaddingY,
|
||||
);
|
||||
|
||||
/// Padding for a bordered control. A BoxDecoration border adds its width to
|
||||
/// the Container's layout, so the stroke comes out of the padding and the
|
||||
/// outer height stays [controlHeight] either way.
|
||||
EdgeInsets get borderedControlPadding => EdgeInsets.symmetric(
|
||||
horizontal: buttonPaddingX - controlBorderWidth,
|
||||
vertical: controlPaddingY - controlBorderWidth,
|
||||
);
|
||||
|
||||
/// Text fields are bordered controls.
|
||||
EdgeInsets get textFieldPadding => borderedControlPadding;
|
||||
|
||||
// ---- text ----
|
||||
//
|
||||
// The body text ladder. [fontSize] is the control font - what a button
|
||||
// label, a field's text and a select trigger render at - and these are the
|
||||
// sizes for text that ISN'T inside a control: dialog copy, headings, hints.
|
||||
//
|
||||
// Dialog copy reading a step larger than the button labels beneath it is
|
||||
// deliberate - content and controls are different things. The bug was never
|
||||
// that they differed, it's that they were UNLINKED: these were hardcoded
|
||||
// 12/14/18 times `scaling`, while the control font comes off this class and
|
||||
// is deliberately not scaled. So the intended 12-vs-10 held at scaling 1.0
|
||||
// and drifted to 14.4-vs-10 at 1.2. The ratio moved with scaling, which is
|
||||
// the actual defect.
|
||||
//
|
||||
// Deriving them from [fontSize] pins the ratio. At both canonical densities
|
||||
// fontSize is 10, so these come out 12/14/18 - exactly the numbers they
|
||||
// replaced, so nothing shifts at scaling 1.0.
|
||||
|
||||
/// The one step BELOW the control font - a caption sat under something,
|
||||
/// not beside it. A property row's subtitle and description, and the same
|
||||
/// tier anything else that explains a control rather than labelling it
|
||||
/// should reach for.
|
||||
///
|
||||
/// This rung didn't exist. The ladder only ever went UP from [fontSize],
|
||||
/// because in A&A the control font IS the smallest thing on screen - so a
|
||||
/// page needing a caption had nowhere to go and hardcoded one. x0.875 lands
|
||||
/// on 9 against a 10px control font, and 14 against a 16px one.
|
||||
double get textXxs => (fontSize * 0.875).roundToDouble();
|
||||
|
||||
/// Content that sits near controls without being one - dialog copy, page
|
||||
/// headings, hints.
|
||||
///
|
||||
/// x1.1, which lands on the ambient body size: 11 against a 10px control
|
||||
/// font. It was x1.2 (12), matching the literal the shorthands used to
|
||||
/// hardcode - but that put dialog copy and every page heading a fifth above
|
||||
/// the controls beneath them and a pixel above ordinary body text, which
|
||||
/// read as oversized rather than as hierarchy. Weight and colour carry the
|
||||
/// emphasis instead.
|
||||
double get textXs => (fontSize * 1.1).roundToDouble();
|
||||
|
||||
/// Geist's own line box, as a multiple of font size - hhea ascender 1005,
|
||||
/// descender -295, lineGap 0 over a 1000 upem. The package hardcodes Geist
|
||||
/// (see GarageTheme), and a label sets no explicit `height`, so this is the
|
||||
/// ratio its line actually lays out at. Flutter rounds each line to a whole
|
||||
/// pixel, which is why the derivations below round rather than ceil.
|
||||
static const double geistLineRatio = 1.3;
|
||||
|
||||
/// Height of a labelled row's label column - the label on [textXs] with a
|
||||
/// subtitle under it on [textXxs], both on the font's own line box.
|
||||
///
|
||||
/// Worth having as a token because it OUTGROWS [controlHeight] at the small
|
||||
/// tiers: 26 against a 23px compact control. A row sized on controlHeight
|
||||
/// alone therefore got its height from the label rather than from the
|
||||
/// control, which is backwards and moves with the font.
|
||||
double get labelColumnHeight =>
|
||||
(textXs * geistLineRatio).roundToDouble() +
|
||||
(textXxs * geistLineRatio).roundToDouble();
|
||||
|
||||
/// Minimum height of a [PropertyRow]. Clears whichever of the control and
|
||||
/// the label column is taller, plus a step, so neither one is the thing
|
||||
/// setting the row height and a row is the same height with or without a
|
||||
/// subtitle.
|
||||
double get propertyRowHeight =>
|
||||
(labelColumnHeight > controlHeight ? labelColumnHeight : controlHeight) +
|
||||
gapXxs;
|
||||
|
||||
/// Body copy that wants to read a step above the controls.
|
||||
double get textSm => (fontSize * 1.4).roundToDouble();
|
||||
|
||||
/// Headings.
|
||||
double get textLg => (fontSize * 1.8).roundToDouble();
|
||||
|
||||
// ---- layout spacing ----
|
||||
//
|
||||
// The gap scale. Layout spacing BETWEEN widgets - what a call site reaches
|
||||
// for when it puts a `Gap` between two things. Distinct from [controlGap],
|
||||
// which is spacing INSIDE a control, and which is the base unit here.
|
||||
//
|
||||
// Before this existed, call sites picked `Gap(n)` literals by eye. That was
|
||||
// documented rather than derived, and the apps drifted off it - the two
|
||||
// Garage web frontends between them had sixteen distinct gap values,
|
||||
// including a 3, a 5 and ten 14s that no scale would have produced.
|
||||
//
|
||||
// The steps are multiples of [controlGap], which makes [gapMd] equal to
|
||||
// [containerGap] and [gapXl] equal to [containerPadding] at both densities.
|
||||
// That agreement isn't arranged, it's what those two tokens already were -
|
||||
// which is the evidence the base unit is right.
|
||||
//
|
||||
// Rounded because the normal density's base is 5, and a 1.5x step off it
|
||||
// lands on 7.5. A fractional gap isn't wrong the way a fractional line box
|
||||
// is (nothing derives from it), but a whole pixel won't seam on a fractional
|
||||
// device ratio, so it's free to keep them whole.
|
||||
|
||||
/// Hairline separation - a label sat directly above its value.
|
||||
double get gapXxs => (controlGap * 0.5).roundToDouble();
|
||||
|
||||
/// Tight. Icon-adjacent, or items that read as one unit.
|
||||
double get gapXs => controlGap;
|
||||
|
||||
/// Snug, between [gapXs] and the default.
|
||||
double get gapSm => (controlGap * 1.5).roundToDouble();
|
||||
|
||||
/// The default. "These two things are related but distinct." When a call
|
||||
/// site has no particular reason to pick another step, it wants this one.
|
||||
double get gapMd => controlGap * 2;
|
||||
|
||||
/// Section-level: separates groups within a panel or form.
|
||||
double get gapLg => controlGap * 3;
|
||||
|
||||
/// Between major blocks of a layout.
|
||||
double get gapXl => controlGap * 4;
|
||||
|
||||
/// The largest step - page-level separation, above the panel scale.
|
||||
double get gapXxl => controlGap * 6;
|
||||
}
|
||||
|
||||
/// Icon sizes. `small` is the control icon and is derived from the density's
|
||||
/// line box so it can never drift from the text beside it.
|
||||
class IconThemeTokens {
|
||||
const IconThemeTokens({
|
||||
required this.small,
|
||||
required this.medium,
|
||||
required this.large,
|
||||
});
|
||||
|
||||
final IconThemeData small;
|
||||
final IconThemeData medium;
|
||||
final IconThemeData large;
|
||||
|
||||
static IconThemeTokens forDensity(
|
||||
Density density,
|
||||
double scaling,
|
||||
Color color,
|
||||
) => IconThemeTokens(
|
||||
small: IconThemeData(size: density.iconSize, color: color),
|
||||
medium: IconThemeData(size: 20 * scaling, color: color),
|
||||
large: IconThemeData(size: 24 * scaling, color: color),
|
||||
);
|
||||
}
|
||||
|
||||
// The apps first class theme data. Everything the widgets used to pull off
|
||||
// shadcns ThemeData (colours, the global scaling, radius tokens, icon sizes,
|
||||
// the optional surface glass) now lives here as our own thing.
|
||||
class ThemeData {
|
||||
ThemeData({
|
||||
required this.colorScheme,
|
||||
this.scaling = 1.0,
|
||||
this.radius = 0.5,
|
||||
this.surfaceOpacity,
|
||||
this.surfaceBlur,
|
||||
this.enableFeedback,
|
||||
this.panelRadius = 10,
|
||||
this.panelGap = 5,
|
||||
Density density = const Density(),
|
||||
Typography? typography,
|
||||
IconThemeTokens? iconTheme,
|
||||
}) : density = density,
|
||||
// typography and icon sizes are derived from the density so the control
|
||||
// type, the control icon and the control height all move together.
|
||||
typography = typography ?? Typography.forDensity(density),
|
||||
iconTheme =
|
||||
iconTheme ??
|
||||
IconThemeTokens.forDensity(density, scaling, colorScheme.foreground);
|
||||
|
||||
final ColourScheme colorScheme;
|
||||
|
||||
// chrome panel layout - corner radius of the docked panels, and the gap
|
||||
// around + between them in the shell.
|
||||
final double panelRadius;
|
||||
final double panelGap;
|
||||
|
||||
final Typography typography;
|
||||
|
||||
final Density density;
|
||||
|
||||
// haptic/click feedback toggle. null = decide by platform (mobile on).
|
||||
final bool? enableFeedback;
|
||||
|
||||
// used by controls that behave differently on touch platforms.
|
||||
TargetPlatform get platform => defaultTargetPlatform;
|
||||
|
||||
// global ui scale. was shadcns AdaptiveScaling(0.75). widgets multiply their
|
||||
// paddings/sizes by this to stay the size they always were.
|
||||
final double scaling;
|
||||
|
||||
// base radius (rem-ish). the tokens below are derived from it exactly the way
|
||||
// shadcn derived theirs (radius * step).
|
||||
final double radius;
|
||||
|
||||
final IconThemeTokens iconTheme;
|
||||
|
||||
// optional surface glassmorphism - null means opaque / no blur, same defaults
|
||||
// shadcn shipped.
|
||||
final double? surfaceOpacity;
|
||||
final double? surfaceBlur;
|
||||
|
||||
double get radiusXs => radius * 4;
|
||||
double get radiusSm => radius * 8;
|
||||
double get radiusMd => radius * 12;
|
||||
double get radiusLg => radius * 16;
|
||||
double get radiusXl => radius * 20;
|
||||
double get radiusXxl => radius * 24;
|
||||
|
||||
BorderRadius get borderRadiusXs => BorderRadius.circular(radiusXs);
|
||||
BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm);
|
||||
BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd);
|
||||
BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg);
|
||||
BorderRadius get borderRadiusXl => BorderRadius.circular(radiusXl);
|
||||
BorderRadius get borderRadiusXxl => BorderRadius.circular(radiusXxl);
|
||||
|
||||
Radius get radiusMdRadius => Radius.circular(radiusMd);
|
||||
Radius get radiusLgRadius => Radius.circular(radiusLg);
|
||||
Radius get radiusXlRadius => Radius.circular(radiusXl);
|
||||
|
||||
ThemeData copyWith({
|
||||
ColourScheme? colorScheme,
|
||||
double? scaling,
|
||||
double? radius,
|
||||
double? surfaceOpacity,
|
||||
double? surfaceBlur,
|
||||
Typography? typography,
|
||||
Density? density,
|
||||
bool? enableFeedback,
|
||||
double? panelRadius,
|
||||
double? panelGap,
|
||||
IconThemeTokens? iconTheme,
|
||||
}) {
|
||||
return ThemeData(
|
||||
colorScheme: colorScheme ?? this.colorScheme,
|
||||
scaling: scaling ?? this.scaling,
|
||||
radius: radius ?? this.radius,
|
||||
surfaceOpacity: surfaceOpacity ?? this.surfaceOpacity,
|
||||
surfaceBlur: surfaceBlur ?? this.surfaceBlur,
|
||||
typography: typography ?? this.typography,
|
||||
density: density ?? this.density,
|
||||
enableFeedback: enableFeedback ?? this.enableFeedback,
|
||||
panelRadius: panelRadius ?? this.panelRadius,
|
||||
panelGap: panelGap ?? this.panelGap,
|
||||
iconTheme: iconTheme ?? this.iconTheme,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
|
||||
import "package:garage_ui/theme/garage_theme.dart";
|
||||
|
||||
// The apps type scale. shadcn exposed a big Typography object; the GarageUI widgets
|
||||
// only reach for the small set below. `small` is a size style, `medium`/`normal`
|
||||
// are weight styles, and `mono` swaps the font family while inheriting the
|
||||
// ambient size/weight unless a caller overrides them.
|
||||
class Typography {
|
||||
const Typography({
|
||||
this.normal = const TextStyle(fontWeight: FontWeight.w400),
|
||||
this.medium = const TextStyle(fontWeight: FontWeight.w500),
|
||||
this.semiBold = const TextStyle(fontWeight: FontWeight.w600),
|
||||
// `small` is the shared control font used by buttons, fields and selects.
|
||||
// Prefer [Typography.forDensity] over setting this by hand - the size
|
||||
// and line height belong to the density, and the whole control geometry
|
||||
// chain hangs off them.
|
||||
this.small = const TextStyle(
|
||||
fontSize: 10,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
});
|
||||
|
||||
/// Builds the control type from the density, so `small` can never drift from
|
||||
/// the line box the control heights are derived from.
|
||||
factory Typography.forDensity(Density density) => Typography(
|
||||
small: TextStyle(
|
||||
fontSize: density.fontSize,
|
||||
height: density.lineHeight,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
|
||||
final TextStyle normal;
|
||||
final TextStyle medium;
|
||||
|
||||
/// Emphasis above [medium] - section labels, a dialog's title. Was a
|
||||
/// FontWeight.w600 literal in menu.dart, properties.dart and toast.dart.
|
||||
final TextStyle semiBold;
|
||||
final TextStyle small;
|
||||
|
||||
TextStyle sansStyle(TextStyle style) =>
|
||||
GoogleFonts.geist(textStyle: style, fontWeight: style.fontWeight);
|
||||
|
||||
TextStyle get mono => GoogleFonts.geistMono();
|
||||
|
||||
TextStyle monoStyle(TextStyle style) =>
|
||||
GoogleFonts.geistMono(textStyle: style, fontWeight: style.fontWeight);
|
||||
}
|
||||
|
||||
// shadcn hung these little text helpers off every widget (usually a Text). they
|
||||
// merge a style change over whatever DefaultTextStyle is in scope. sizes are
|
||||
// multiplied by the theme scaling so they track the rest of the ui.
|
||||
extension TextStyleExtension on Widget {
|
||||
// sizes come off the density's text ladder, NOT a literal times scaling -
|
||||
// see Density.textXs for why. scaling is left out on purpose: Density isn't
|
||||
// scaled, and these have to stay in step with the control font.
|
||||
Widget xSmall() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(fontSize: t.density.textXs),
|
||||
);
|
||||
Widget small() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(fontSize: t.density.textSm),
|
||||
);
|
||||
Widget large() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(fontSize: t.density.textLg),
|
||||
);
|
||||
|
||||
Widget medium() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => const TextStyle(fontWeight: FontWeight.w500),
|
||||
);
|
||||
Widget semiBold() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => const TextStyle(fontWeight: FontWeight.w600),
|
||||
);
|
||||
Widget bold() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => const TextStyle(fontWeight: FontWeight.w700),
|
||||
);
|
||||
|
||||
Widget muted() => _StyledText(
|
||||
child: this,
|
||||
style: (t) => TextStyle(color: t.colorScheme.mutedForeground),
|
||||
);
|
||||
}
|
||||
|
||||
typedef _StyleFromTheme = TextStyle Function(ThemeData theme);
|
||||
|
||||
class _StyledText extends StatelessWidget {
|
||||
const _StyledText({required this.child, required this.style});
|
||||
|
||||
final Widget child;
|
||||
final _StyleFromTheme style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = GarageTheme.of(context);
|
||||
return DefaultTextStyle.merge(style: style(theme), child: child);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user