The Garage SDKs, in the open

garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+490
View File
@@ -0,0 +1,490 @@
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/button.dart";
import "package:garage_ui/theme/garage_theme.dart";
// A sheet that comes up from the bottom edge. The phone's version of a dialog
// for anything thats more than a sentence and two buttons - a list to pick
// from, a handful of per-item controls, a search box with results under it.
//
// Its the same surface an AlertDialog is (card, the section border, dimmed
// page behind), just anchored to the bottom and only rounded on top, because
// thats where a thumb is. Flat like everything else here - no shadow, the
// border and the scrim are what lift it off the page.
//
// On a wide window it doesnt go edge to edge. A 1400px sheet of four rows is
// a banner, not a sheet, so it caps at [maxWidth] and sits centred.
/// Shows a modal sheet and returns whatever it gets popped with.
///
/// [builder] builds the body. The sheet handles its own scrolling, so hand it
/// a Column of rows rather than a ListView - unless the body IS a long list,
/// in which case pass [scrollable] false and bring your own scroller (it gets
/// a bounded height to work in).
Future<T?> showSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
String? title,
String? subtitle,
Widget? trailing,
bool dismissible = true,
bool scrollable = true,
double maxHeightFactor = 0.85,
double maxWidth = 560,
bool useRootNavigator = true,
}) {
final nav = Navigator.of(context, rootNavigator: useRootNavigator);
final themes = InheritedTheme.capture(from: context, to: nav.context);
return nav.push<T>(
_SheetRoute<T>(
themes: themes,
dismissible: dismissible,
builder: (ctx) => Sheet(
title: title,
subtitle: subtitle,
trailing: trailing,
scrollable: scrollable,
maxHeightFactor: maxHeightFactor,
maxWidth: maxWidth,
onClose: dismissible ? () => Navigator.of(ctx).maybePop() : null,
child: Builder(builder: builder),
),
),
);
}
class _SheetRoute<T> extends PopupRoute<T> {
_SheetRoute({
required this.builder,
required this.themes,
required this.dismissible,
});
final WidgetBuilder builder;
final CapturedThemes themes;
final bool dismissible;
// the drag handle moves the route by hand. controller is protected, so the
// sheet asks through here rather than grabbing it
void dragTo(double v) => controller?.value = v.clamp(0.0, 1.0);
void settle() => controller?.animateTo(1, curve: Curves.easeOutCubic);
@override
Color? get barrierColor => const Color(0x00000000);
// the scrim is painted below so it can fade with the drag, the barrier is
// just the thing that catches the tap
@override
bool get barrierDismissible => dismissible;
@override
String? get barrierLabel => "Dismiss";
@override
Duration get transitionDuration => const Duration(milliseconds: 240);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 180);
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return themes.wrap(
_SheetDragScope(
route: this,
child: Builder(builder: builder),
),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final curve = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: FadeTransition(
opacity: curve,
child: ColoredBox(color: const Color(0x99000000)),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(curve),
child: child,
),
),
],
);
}
}
// lets the handle reach the route to drive it by hand while its being dragged
// down, the same way a real sheet follows your finger rather than waiting for
// you to let go
class _SheetDragScope extends InheritedWidget {
const _SheetDragScope({required this.route, required super.child});
final _SheetRoute<dynamic> route;
static _SheetRoute<dynamic>? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_SheetDragScope>()?.route;
@override
bool updateShouldNotify(_SheetDragScope old) => old.route != route;
}
/// The sheet surface on its own, for when you want it somewhere other than a
/// modal route - pinned under a page, say. [showSheet] wraps one of these.
class Sheet extends StatefulWidget {
const Sheet({
super.key,
required this.child,
this.title,
this.subtitle,
this.trailing,
this.onClose,
this.scrollable = true,
this.maxHeightFactor = 0.85,
this.maxWidth = 560,
});
final Widget child;
final String? title;
final String? subtitle;
/// Far end of the header, before the close button. A count, a single action.
final Widget? trailing;
/// Null hides the close button (and the handle stops dismissing).
final VoidCallback? onClose;
final bool scrollable;
final double maxHeightFactor;
final double maxWidth;
@override
State<Sheet> createState() => _SheetState();
}
class _SheetState extends State<Sheet> {
double _dragged = 0;
double _height = 1;
_SheetRoute<dynamic>? get _route => _SheetDragScope.maybeOf(context);
void _dragUpdate(DragUpdateDetails d) {
if (widget.onClose == null) return;
final route = _route;
if (route == null) return;
_dragged = (_dragged + d.delta.dy).clamp(0.0, _height);
// drive the route's own controller so the scrim fades with the finger
route.dragTo(1 - _dragged / _height);
}
void _dragEnd(DragEndDetails d) {
if (widget.onClose == null) return;
final route = _route;
if (route == null) return;
final v = d.primaryVelocity ?? 0;
final gone = v > 700 || _dragged > _height * 0.35;
_dragged = 0;
if (gone) {
widget.onClose!();
} else {
route.settle();
}
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final d = theme.density;
final mq = MediaQuery.of(context);
final radius = Radius.circular(theme.panelRadius + 4);
final header = GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: _dragUpdate,
onVerticalDragEnd: _dragEnd,
excludeFromSemantics: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: d.gapSm, bottom: d.gapXs),
child: Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: cs.mutedForeground.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
),
if (widget.title != null || widget.onClose != null)
Padding(
padding: EdgeInsets.fromLTRB(
d.containerPadding,
0,
d.gapMd,
d.gapSm,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.title != null)
Semantics(
header: true,
child: Text(
widget.title!,
style: theme.typography.semiBold.copyWith(
fontSize: d.textSm,
),
),
),
if (widget.subtitle != null) ...[
SizedBox(height: d.gapXxs),
Text(
widget.subtitle!,
style: TextStyle(
fontSize: d.textXxs,
color: cs.mutedForeground,
),
),
],
],
),
),
if (widget.trailing != null) ...[
SizedBox(width: d.gapSm),
widget.trailing!,
],
if (widget.onClose != null) ...[
SizedBox(width: d.gapXs),
IconButton.ghost(
semanticLabel: "Close",
onPressed: widget.onClose,
icon: const Icon(LucideIcons.x),
),
],
],
),
),
Container(height: 1, color: cs.divider),
],
),
);
// keyboard pushes the whole sheet up, not just the bit with the field in
final maxH = (mq.size.height - mq.viewInsets.bottom - mq.padding.top) *
widget.maxHeightFactor;
Widget body = widget.child;
if (widget.scrollable) {
body = SingleChildScrollView(
padding: EdgeInsets.only(bottom: mq.padding.bottom + d.gapMd),
child: body,
);
} else {
body = Padding(
padding: EdgeInsets.only(bottom: mq.padding.bottom),
child: body,
);
}
return Padding(
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.maxWidth, maxHeight: maxH),
child: LayoutBuilder(
builder: (context, c) {
_height = c.maxHeight;
return Semantics(
scopesRoute: true,
namesRoute: widget.title != null,
label: widget.title,
explicitChildNodes: true,
child: Container(
decoration: BoxDecoration(
color: cs.card,
borderRadius: BorderRadius.vertical(top: radius),
border: Border(
top: BorderSide(color: cs.propertiesSectionBorder),
left: BorderSide(color: cs.propertiesSectionBorder),
right: BorderSide(color: cs.propertiesSectionBorder),
),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
// the list takes what the header leaves and no more
widget.scrollable
? Flexible(child: body)
: Expanded(child: body),
],
),
),
);
},
),
),
);
}
}
/// A tappable row for a sheet or any other plain list - icon, a label, an
/// optional muted line under it and something on the end.
///
/// The list row the kit didnt have. A ghost Button stretched full width got
/// close, but it centres a single line and has nowhere for the second one.
class SheetRow extends StatefulWidget {
const SheetRow({
super.key,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onPressed,
this.selected = false,
this.destructive = false,
});
final Widget title;
final Widget? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onPressed;
/// Tinted like the rail marks the page youre on.
final bool selected;
/// Title and icon in the destructive colour.
final bool destructive;
@override
State<SheetRow> createState() => _SheetRowState();
}
class _SheetRowState extends State<SheetRow> {
bool _hover = false;
bool _down = false;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final d = theme.density;
final enabled = widget.onPressed != null;
Color? fill;
if (widget.selected) fill = cs.secondary;
if (enabled && (_hover || _down)) fill = cs.rowHovered;
final fg = widget.destructive ? cs.destructive : cs.foreground;
Widget row = Container(
constraints: BoxConstraints(minHeight: d.controlHeight + d.gapSm),
padding: EdgeInsets.symmetric(
horizontal: d.containerPadding,
vertical: d.gapSm,
),
color: fill,
child: Row(
children: [
if (widget.leading != null) ...[
IconTheme.merge(
data: IconThemeData(
color: widget.destructive ? cs.destructive : cs.mutedForeground,
),
child: widget.leading!,
),
SizedBox(width: d.gapMd),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
DefaultTextStyle.merge(
style: TextStyle(color: fg),
child: widget.title,
),
if (widget.subtitle != null) ...[
SizedBox(height: d.gapXxs),
DefaultTextStyle.merge(
style: TextStyle(
fontSize: d.textXxs,
color: cs.mutedForeground,
),
child: widget.subtitle!,
),
],
],
),
),
if (widget.trailing != null) ...[
SizedBox(width: d.gapSm),
IconTheme.merge(
data: IconThemeData(color: cs.mutedForeground),
child: widget.trailing!,
),
],
],
),
);
if (!enabled) return row;
return Semantics(
button: true,
selected: widget.selected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapCancel: () => setState(() => _down = false),
onTapUp: (_) => setState(() => _down = false),
onTap: widget.onPressed,
child: row,
),
),
);
}
}