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
491 lines
15 KiB
Dart
491 lines
15 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|