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 showSheet({ 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( _SheetRoute( 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 extends PopupRoute { _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 animation, Animation secondaryAnimation, ) { return themes.wrap( _SheetDragScope( route: this, child: Builder(builder: builder), ), ); } @override Widget buildTransitions( BuildContext context, Animation animation, Animation 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( 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 route; static _SheetRoute? 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 createState() => _SheetState(); } class _SheetState extends State { double _dragged = 0; double _height = 1; _SheetRoute? 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 createState() => _SheetRowState(); } class _SheetRowState extends State { 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, ), ), ); } }