The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
@@ -0,0 +1,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user